pax_global_header 0000666 0000000 0000000 00000000064 15003201720 0014500 g ustar 00root root 0000000 0000000 52 comment=d3bcc2e368e7399af664812cbd67df1dc827d58b interimap-0.5.8/ 0000775 0000000 0000000 00000000000 15003201720 0013502 5 ustar 00root root 0000000 0000000 interimap-0.5.8/.gitignore 0000664 0000000 0000000 00000000027 15003201720 0015471 0 ustar 00root root 0000000 0000000 *~ *.bak /build/ /.pc/ interimap-0.5.8/COPYING 0000664 0000000 0000000 00000104517 15003201720 0014545 0 ustar 00root root 0000000 0000000 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . interimap-0.5.8/Changelog 0000664 0000000 0000000 00000047114 15003201720 0015323 0 ustar 00root root 0000000 0000000 interimap (0.5.8) upstream; + Port tests and documentation to Dovecot 2.4. Running the test suite now require Dovecot 2.3 or later. - Makefile: Replace '$(dir $@)' with '$(@D)'. - tests/*/t: Replace filetype=sh with filetype=bash in vim's hints. - tests/certs/generate: Generate X.509 version 3 CA, and pass CA:TRUE as basic constraint. This fixes the test suite with OpenSSL 3.2 with defaults to X.509v3 and CA:FALSE. - tests: Explicitly pass `-in /dev/stdin` to openssl(1). - tests: Check that that pullimap locks its statefile. -- Guilhem Moulin Sat, 26 Apr 2025 18:03:28 +0200 interimap (0.5.7) upstream; * interimap: create database with mode 0600 (but don't change mode of existing databases). The file was previously created with mode 0644 minus umask restrictions, which for permissive umask(2)s is too open. That being said its parent directory is created with restricted mode 0700 so the impact is limited. pullimap, on the other hand, already used mode 0600 for state file creation. * Major Makefile refactoring: improve DESTDIR= handling, add new targets 'all-nodoc', 'install-nodoc', and 'installcheck'. Also, rename 'test' target to 'check'. * `make install` now installs Net/IMAP/InterIMAP.pm to /usr/local/lib/site_perl by default rather than /usr/local/share/perl5 (which is not in @INC as of perl 5.34.0-3 from Debian sid). The installation directory is configurable with sitelib=. * Refactor test harness so one can check the source with `tests/run foo`; what's been built with `INTERIMAP_I=./lib INTERIMAP_PATH=./build ./tests/run foo`, and what's installed with `INTERIMAP_I="" INTERIMAP_PATH=/usr/bin tests/run foo`. * Split interimap and pullimap test suites. + Improve message for missing untagged UIDNEXT responses, which we require but are omitted from some servers. + tests/tls-protocols: downgrade OpenSSL security level to 0, which is required to test TLS version <1.2 on systems with higher security levels, see SSL_CTX_set_security_level(3ssl). Adapted from a patch from for Unbuntu. + tests/tls-*: bump Dovecot's ssl_min_protocol to TLSv1.2, which is the default as of dovecot 1:2.3.18+dfsg1-1 from Debian sid. + `make clean` now cleans test certificates and key material. + Add 'use lib "./lib";' to interimap and pullimap, so the programs can be run directly from the source directory. The directory is substituted with $(sitelib) at compile time (and the line is commented out if $(sitelib) is found in @INC). + doc/build.md: update documentation, and add a new section for how to install without root privileges. + Add Documentation=https://guilhem.org/interimap/... URIs to .service files. - Don't hardcode path to interimap/pullimap in .service files, and instead use $(bindir) (expanded at compile time). - tests/certs/generate: redirect known error output to the standard output. - tests/certs/generate: use custom openssl.cnf to avoid depending on the system default. - tests/pullimap: allow easy exclusion of --idle'ing tests. -- Guilhem Moulin Sun, 27 Feb 2022 16:24:31 +0100 interimap (0.5.6) upstream; - Bump required Net::SSLeay version to 1.86_06 as it's when get_version() was introduced. - doc/template.html: remove type attribute from element. -- Guilhem Moulin Fri, 01 Jan 2021 16:05:53 +0100 interimap (0.5.5) upstream; * libinterimap: remove default SSL_protocols value "!SSLv2 !SSLv3 !TLSv1 !TLSv1.1" and use the system default instead. As of Debian Buster (OpenSSL 1.1.1) this does not make a difference, however using the system default provides better compatibility with future libssl versions. * libinterimap: deprecate SSL_protocols, obsoleted by new settings SSL_protocol_{min,max}. Using the libssl interface simplifies our protocol black/whitelist greatly; this only allows simple min/max bounds, but holes are arguably not very useful here. * libinterimap: use default locations for trusted CA certificates when neither CAfile nor CApath are set. In particular, OpenSSL's default locations can be overridden by the SSL_CERT_FILE resp. SSL_CERT_DIR environment variables, see SSL_CTX_load_verify_locations(3ssl). * libinterimap: _start_ssl() now fails immediately with OpenSSL <1.1.0. It could in principle still work with earlier versions if the new settings SSL_protocol_{min,max} are not used, however it's cumbersome to do individual checks for specific settings, let alone maintain test coverage with multiple OpenSSL versions. * libinterimap: new option SSL_ciphersuites to set the TLSv1.3 ciphersuites; also, clarify that SSL_cipherlist only applies to TLSv1.2 and below, see SSL_CTX_set_cipher_list(3ssl). + `make release`: also bump libinterimap version and pin it in 'use' declarations. + Make error messages more uniform and consistent. - libinterimap: use Net::SSLeay::get_version() to get the protocol version string. - test suite: `mv tests/snippets tests/config` - tests/tls-protocols: use custom OpenSSL configuration file with MinProtocol=None so we can test TLSv1 as well, not just TLSv1.2 and later. - test suite: explicitly set ssl_min_protocol=TLSv1 in the Dovecot configuration file (the default as of 2.3.11.3), hence running TLS tests now require Dovecot 2.3 or later. - documentation: simplify SSL options in the sample configuration files. - README: suggest 'restrict,command="/usr/bin/doveadm exec imap"' as authorized_keys(5) options. - README: suggest ControlPath=$XDG_RUNTIME_DIR/ssh-imap-%C for the SSH transport (note that variable expansion is only available in OpenSSH 8.4 and later). - test suite: ensure we haven't started speaking IMAP when the SSL/TLS handshake is aborted (unless STARTTLS is used to upgrade to connection). - documentation: clarify that known TLS protocol versions depend on the OpenSSL version used. -- Guilhem Moulin Sat, 26 Dec 2020 23:11:10 +0100 interimap (0.5.4) upstream; * libinterimap: make SSL_verify also checks that the certificate Subject Alternative Name (SAN) or Subject CommonName (CN) matches the hostname or IP literal specified by the 'host' option. Previously it was only checking the chain of trust. This bumps the minimum Net::SSLeay version to 1.83 and OpenSSL version to 1.0.2 (when SSL_verify is used). * libinterimap: add support for the TLS SNI (Server Name Indication) extension, controlled by the new 'SSL_hostname' option. The default value of that option is the value of the 'host' option when it is hostname, and the empty string (which disables SNI) when it is an IP literal. + libinterimap: show the matching pinned SPKI in --debug mode. + test suite: always generate new certificates on `make test`. + test suite: sign all test certificates with the same root CA. + libinterimap: factor out hostname/IP parsing. + document that enclosing 'host' value in square brackets forces its interpretation as an IP literal (hence skips name resolution). + Makefile: new 'release' target; also, change the tag format from upstream/$VERSION to v$VERSION. - documentation: replace example.org with example.net for consistency. - rename 'debian' branch to 'debian/latest' for DEP-14 compliance. -- Guilhem Moulin Fri, 11 Dec 2020 11:21:17 +0100 interimap (0.5.3) upstream; * libinterimap: SSL_fingerprint now supports a space-separated list of digests to pin, and succeeds if, and only if, the peer certificate SPKI matches one of the pinned digest values. Specifying multiple digest values can key useful in key rollover scenarios and/or when the server supports certificates of different types (for instance RSA+ECDSA). - libinterimap: 'null-stderr' is now ignored when the 'debug' flag is set (the standard error is never sent to /dev/null). - test suite: use a RSA certificate rather than ECDSA. - test suite: new test with a server offering both RSA+ECDSA certificates. This test requires dovecot-imapd 2.2.31 or later. -- Guilhem Moulin Wed, 09 Dec 2020 15:32:01 +0100 interimap (0.5.2) upstream; - Makefile: remove 'smart' extension from pandoc call to generate manuals (it's no longer supported by pandoc 2.9 which generates \[lq] and \[rq] in the groff output anyway). - libinterimap: fix response injection vulnerability after STARTTLS. For background see https://gitlab.com/muttmua/mutt/-/issues/248 . - libinterimap: abort on PREAUTH greeting received on plaintext connections (set "STARTTLS = NO" to ignore). This is similar to CVE-2020-12398 and CVE-2020-14093. * libinterimap: fail when a capability to ENABLE is missing from the server's CAPABILITY listing. -- Guilhem Moulin Mon, 03 Aug 2020 20:50:41 +0200 interimap (0.5.1) upstream; + pullimap: also compare RFC 5322 date and envelope information in mailbox comparison tests. + interimap, pullimap: remove `use lib` statement. * Makefile: major refactoring, add install and uninstall targets, honor BUILD_DOCDIR and DESTDIR variables. -- Guilhem Moulin Thu, 02 Jul 2020 00:15:03 +0200 interimap (0.5) upstream; Breaking changes: * interimap: when matching mailbox names against the 'ignore-mailbox' pattern, the hierarchy delimiter is substituted with a null character before hand. For instance one should now use '^virtual(?:\x00|$)' to exclude the mailbox named 'virtual' as well as its descendants (regardless of the hierarchy delimiter in use). Other changes: * interimap: the space-separated list of names and/or patterns in 'list-mailbox' can now contain C-style escape sequences (backslash and hexadecimal escape). * interimap: fail when two non-INBOX LIST replies return different separators. This never happens for a single LIST command, but may happen if mailboxes from different namespaces are being listed. The workaround here is to run a new interimap instance for each namespace. * libinterimap: in tunnel mode, use a socket pair rather than two pipes for IPC between the interimap and the IMAP server. Also, use SOCK_CLOEXEC to save an fcntl() call when setting the close-on-exec flag on the socket. * interimap: new option 'list-reference' to specify a reference name. This is useful for synchronizing multiple remote servers against different namespaces belonging to the same local IMAP server (using a different InterIMAP instance for each local namespace <-> remote synchronization, for instance with the newly provided systemd template unit file). * Add a test-suite. (Requires dovecot-imapd, pkill(1) and xxd(1).) * Completely refactor the documentation. In particular, move manpages to a new 'doc' directory, and generate HTML documentation with `make html`. + interimap: write which --target to use in --delete command suggestions. + interimap: avoid caching hierarchy delimiters forever in the database. Instead, use null characters internally, and substitute them with the local and remote hierarchy delimiters (which thus no longer need to match) for IMAP commands. This require a database schema upgrade to alter the mailbox name column type from TEXT to BLOB. + interimap: use the 'user_version' SQLite PRAGMA for database schema version. + interimap, pullimap: in the manpage, clarify that the tunnel command is run following Perl's `exec` semantics: it is passed to `/bin/sh -c` when it contains shell metacharacters; and split into words and passed to execvp(3) otherwise. + interimap, pullimap: redact AUTHENTICATE and LOGIN commands in --debug mode in order to avoid inadvertently receiving credentials in bug reports. --debug can be set twice to spell out these commands in full. + interimap: new option 'log-prefix' to control the prefix of each log entry, depending on the component name and relevant mailbox. + interimap: raise SELECT sample range size from 64 to 256 bytes. - libinterimap: bugfix: hierarchy delimiters in LIST responses were returned as an escaped quoted special, like "\\", not as a single character (backslash in this case). - libinterimap: the parser choked on responses with non-quoted/literal astring containing ']' characters. And LIST responses with non-quoted/literal list-mailbox names containing '%', '*' or ']' characters. - libinterimap: quote() the empty string as "" instead of a 0-length literal. (This saves 3 bytes + one round-trip on servers not supporting non-synchronizing literals, and 4 bytes otherwise.) - interimap: unlike what the documentation said, spaces where not allowed in the 'list-select-opts' configuration option, so at maximum one selector could be used for the initial LIST command. - interimap: unlike what the documentation said, 'ignore-mailbox' was not ignored when names were specified as command line arguments. - interimap: accept comma-separated values for --target. - interimap: --rename of a \NonExistent mailbox didn't trigger a RENAME command on the local/remote IMAP servers, nor an update of the 'mailboxes' table. - interimap: don't try to delete \NoSelect mailboxes (it's an error per RFC 3501 sec. 6.3.4). - interimap: SQLite were not enforcing foreign key constraints (setting the 'foreign_keys' PRAGMA during a transaction is a documented no-op). - interimap: fix handling of mod-sequence values greater or equal than 2 << 63. - libinterimap: use directories relative to $HOME for the XDG environment variables default values. Previously getpwuid() was called to determine the user's home directory, while the XDG specification explicitly mentions $HOME. Conveniently our docs always mentioned ~/, which on POSIX-compliant systems expands to the value of the variable HOME. (Cf. Shell and Utilities volume of POSIX.1-2017, sec. 2.6.1.) - libinterimap: don't panic() when inflate() reports the end of the compression stream is reached. - libinterimap: the 'compress' boolean wasn't honored. - libinterimap: fix STARTTLS directive, broken since 0.2. - libinterimap: push_flag_updates(): the UNCHANGEDSINCE test from the CONDSTORE extension was incorrectly placed after the flag list in UID STORE commands. - libinterimap: push_flag_updates(): ignore UIDs for which no untagged FETCH response was received. - libinterimap: push_flag_updates(): don't ignores received updates (by another client) to a superset of the desired flag list. - libinterimap: avoid sending large UID EXPUNGE|FETCH|STORE commands as they might exceed the server's max acceptable command size; these commands are now split into multiple (sequential) commands when their set representation exceeds 4096 bytes in size. Performance could be improved by pipelining but given the scope of this software (synchronization) it's unlikely to make any difference in practice. This is a also a workaround for a bug in Dovecot 2.3.4: https://dovecot.org/pipermail/dovecot/2019-November/117522.html - interimap: for the reason explained above, limit number of messages to 128 per APPEND command (only on servers advertising MULTIAPPEND, for other servers the number remains 1). - interimap: gracefully ignore messages with a NIL RFC822 attribute. - pullimap: treat messages with a NIL RFC822 attribute as empty. - pullimap: fix mangling of data lines starting with a dot (when an RFC 5322 line starts with a '.', double it). -- Guilhem Moulin Sat, 14 Dec 2019 18:42:25 +0100 interimap (0.4) upstream; * pullimap: replace non RFC 5321-compliant envelope sender addresses (received by the IMAP FETCH ENVELOPE command) with the null address <>. * pullimap, interimap: take configuration filename (default: "config") relative to $XDG_CONFIG_HOME/$NAME (or ~/.config/$NAME), to comply with the XDG specification. Thus the previous default config file $XDG_CONFIG_HOME/$NAME should become $XDG_CONFIG_HOME/$NAME/config. * Library: add support for TLSv1.3 (on recent enough Net::SSLeay), and change "SSL_protocols" default value from "!SSLv2 !SSLv3" to "!SSLv2 !SSLv3 !TLSv1 !TLSv1.1". + Library: new API idle_start() and idle_stop(). + Add support for untagged ESEARCH responses from RFC 4731. + pullimap: Use extended SEARCH commands (RFC 4731) if supported by the server to search old mail and EXPUNGE them. + pullimap, interimap: don't autocreate statefile or database in long-lived mode (when --watch or --idle is set). Instead, an error is raised if the statefile or database doesn't exist. - Ensure the lower bound of UID ranges is at least 1. - Fix manpage generation with pandoc >=2.1. - Specify minimum Perl and Net::SSLeay versions. - interimap.service: use --watch=60 rather than --notify, because dovecot's NOTIFY extension doesn't seem to work so well as of 2.2.27. -- Guilhem Moulin Sun, 20 Jan 2019 20:30:08 +0100 interimap (0.3) upstream; + New script 'pullimap', to pull mails from an IMAP mailbox and deliver them to an SMTP session. + Convert manpage format from groff to pandoc. + interimap: Add support for IMAP NOTIFY [RFC 5465]. + 'fingerprint' now only pins the cert's SPKI, not the cert itself (require Net::SSLeay >=1.73). - Fix byte count for compression streams. - Add an option 'SSL_protocols' to list SSL protocols to enable or disable. The default value, "!SSLv2 !SSLv3", enables only TLSv1 and above. - Ensure "inbox" is always used in upper-case internally. - Ensure the FD_CLOEXEC bit is 1 on sockets, logger and state files. - Try again if connect(2) was interrupted. - interimap: fix memory leak. - interimap: use SQLite's own locking mechanism (instead of rolling our own). - interimap: when resuming a sync, only consider UIDs greater than a known UIDNEXT. -- Guilhem Moulin Thu, 01 Dec 2016 14:37:50 +0100 interimap (0.2) upstream; * Add support for the IMAP COMPRESS extension [RFC4978]. By default enabled for the remote server, and disabled for the local server. * Add traffic statistics after closing the connection to the IMAP server. * Add a configuration option 'null-stderr=YES' to send STDERR to /dev/null for type=tunnel. * Exit with return value 0 when receiving a SIGTERM. * Add SSL options SINGLE_ECDH_USE, SINGLE_DH_USE, NO_SSLv2, NO_SSLv3 and NO_COMPRESSION to the compiled-in CTX options. * Use SSL_MODE_AUTO_RETRY to avoid SSL_read failures during a handshake. * Rename the 'SSL_verify_trusted_peer', 'SSL_ca_path', and 'SSL_cipher_list' options to 'SSL_CApath', 'SSL_verify' and 'SSL_cipherlist', respectively. * Add an option 'SSL_CAfile' to specify a file containing trusted certificates to use during server certificate authentication. * Replace IO::Socket::SSL dependency by the lower level Net::SSLeay. * Accept non-fully qualified commands. * Replace IO::Socket::INET dependency by the lower lever Socket to enable IPv6. (Both are core Perl module.) * Add a configuration option 'proxy' to proxy TCP connections to the IMAP server. * Set X.509 certificate purpose to 'SSL Server' for SSL_verify=YES. * Display the certificate chain, SSL protocol and cipher in debug mode. * Bug fix: don't delete the lockfile if another instance of interimap is running. * Display source UIDs upon APPEND. Previously only target UIDs where displayed in non-debug mode. -- Guilhem Moulin Wed, 09 Sep 2015 00:44:35 +0200 interimap (0.1) upstream; * Initial public release. Development was started in July 2015. -- Guilhem Moulin Mon, 07 Sep 2015 17:14:42 +0200 interimap-0.5.8/Makefile 0000664 0000000 0000000 00000012451 15003201720 0015145 0 ustar 00root root 0000000 0000000 srcdir ?= . builddir ?= build prefix ?= /usr/local exec_prefix ?= $(prefix) bindir ?= $(exec_prefix)/bin libdir ?= $(exec_prefix)/lib sitelib ?= $(libdir)/site_perl datarootdir ?= $(prefix)/share mandir ?= $(datarootdir)/man man1dir ?= $(mandir)/man1 systemd_userunitdir ?= $(libdir)/systemd/user CSS ?= /usr/share/javascript/bootstrap4/css/bootstrap.css HTML_TEMPLATE ?= $(srcdir)/doc/template.html PROGRAMS = $(addprefix $(builddir)/,interimap pullimap) HTML_FILES = $(patsubst $(srcdir)/doc/%.md,$(builddir)/doc/%.html,$(wildcard $(srcdir)/doc/*.md)) MANUAL_FILES = $(patsubst $(srcdir)/doc/%.md,$(builddir)/doc/%,$(wildcard $(srcdir)/doc/*.[1-9].md)) SERVICE_FILES = $(patsubst $(srcdir)/%.service,$(builddir)/%.service,$(wildcard $(srcdir)/*.service)) all: all-nodoc manual all-nodoc: $(PROGRAMS) $(SERVICE_FILES) doc: manual html manual: $(MANUAL_FILES) html: $(HTML_FILES) $(PROGRAMS): $(builddir)/%: $(srcdir)/% @mkdir -vp -- $(@D) perl -Te "print \"\$$_\\0\" foreach @INC;" | grep -Fxzq -e "$(sitelib)" && prefix="#" || prefix=""; \ sed -r "0,/^(use\\s+\lib\\s+)([\"'])[^\"']*\\2\\s*;/ s||$$prefix\\1\"$(sitelib)\";|" <"$<" >"$@" chmod --reference="$<" -- "$@" # upper case the headers and remove the links $(MANUAL_FILES): $(builddir)/doc/%: $(srcdir)/doc/%.md @mkdir -vp -- $(@D) pandoc -f markdown -t json -- "$<" | $(srcdir)/pandoc2man.jq | pandoc -s -f json -t man -o "$@" $(SERVICE_FILES): $(builddir)/%.service: $(srcdir)/%.service @mkdir -vp -- $(@D) sed "s|@bindir@|$(bindir)|" <"$<" >"$@" testcerts: $(srcdir)/tests/certs/generate check: check-interimap check-pullimap check-interimap: $(builddir)/interimap testcerts INTERIMAP_I=$(srcdir)/lib INTERIMAP_PATH=$(builddir) $(srcdir)/tests/run-all interimap.list check-pullimap: $(builddir)/pullimap testcerts INTERIMAP_I=$(srcdir)/lib INTERIMAP_PATH=$(builddir) $(srcdir)/tests/run-all pullimap.list installcheck: installcheck-interimap installcheck-pullimap installcheck-interimap: testcerts INTERIMAP_I="" INTERIMAP_PATH=$(bindir) $(srcdir)/tests/run-all interimap.list installcheck-pullimap: testcerts INTERIMAP_I="" INTERIMAP_PATH=$(bindir) $(srcdir)/tests/run-all pullimap.list release: @if ! git -C $(srcdir) diff --quiet HEAD -- Changelog interimap pullimap lib/Net/IMAP/InterIMAP.pm; then \ echo "Dirty state, refusing to release!" >&2; \ exit 1; \ fi VERS=$$(dpkg-parsechangelog -l $(srcdir)/Changelog -SVersion 2>/dev/null) && \ if git -C $(srcdir) rev-parse -q --verify "refs/tags/v$$VERS" >/dev/null; then echo "tag exists" 2>/dev/null; exit 1; fi && \ sed -ri "0,/^( -- .*) .*/ s//\\1 $(shell date -R)/" $(srcdir)/Changelog && \ sed -ri "0,/^(our\\s+\\\$$VERSION\\s*=\\s*)'[0-9.]+'\\s*;/ s//\\1'$$VERS';/" \ -- $(srcdir)/interimap $(srcdir)/pullimap && \ sed -ri "0,/^(package\\s+Net::IMAP::InterIMAP\\s+)v[0-9.]+\\s*;/ s//\\1v$$VERS;/" \ -- $(srcdir)/lib/Net/IMAP/InterIMAP.pm && \ sed -ri "0,/^(use\\s+Net::IMAP::InterIMAP\\s+)[0-9.]+(\\s|\\$$)/ s//\\1$$VERS\\2/" \ -- $(srcdir)/interimap $(srcdir)/pullimap && \ git -C $(srcdir) commit -m "Prepare new release v$$VERS." \ -- Changelog interimap pullimap lib/Net/IMAP/InterIMAP.pm && \ git -C $(srcdir) tag -sm "Release version $$VERS" "v$$VERS" $(HTML_FILES): $(builddir)/doc/%.html: $(srcdir)/doc/%.md $(HTML_TEMPLATE) @mkdir -vp -- $(@D) mtime="$$(git -C $(srcdir) --no-pager log -1 --pretty="format:%ct" -- "$<" 2>/dev/null)"; \ [ -n "$$mtime" ] || mtime="$$(date +%s -r "$<")"; \ pandoc -sp -f markdown -t html+smart --css=$(CSS) --template=$(HTML_TEMPLATE) \ --variable=date:"$$(LC_TIME=C date +"Last modified on %a, %d %b %Y at %T %z" -d @"$$mtime")" \ --variable=keywords:"interimap" \ --variable=lang:"en" \ --variable=parent:"$(if $(filter $@,$(builddir)/doc/index.html),,./index.html)" \ --output="$@" -- "$<" INSTALL ?= install INSTALL_PROGRAM ?= $(INSTALL) INSTALL_DATA ?= $(INSTALL) -m0644 install: install-nodoc -$(INSTALL_DATA) -vDt $(DESTDIR)$(man1dir) $(builddir)/doc/interimap.1 $(builddir)/doc/pullimap.1 $(INSTALL_DATA) -vDt $(DESTDIR)$(datarootdir)/doc/pullimap $(srcdir)/pullimap.sample $(INSTALL_DATA) -vDt $(DESTDIR)$(datarootdir)/doc/interimap $(srcdir)/interimap.sample \ $(srcdir)/doc/getting-started.md $(srcdir)/doc/multi-account.md $(srcdir)/README install-nodoc: all-nodoc $(INSTALL_PROGRAM) -vDt $(DESTDIR)$(bindir) $(builddir)/interimap $(builddir)/pullimap $(INSTALL_DATA) -vDT $(srcdir)/lib/Net/IMAP/InterIMAP.pm $(DESTDIR)$(sitelib)/Net/IMAP/InterIMAP.pm $(INSTALL_DATA) -vDt $(DESTDIR)$(systemd_userunitdir) $(SERVICE_FILES) uninstall: rm -vf -- $(DESTDIR)$(bindir)/interimap $(DESTDIR)$(man1dir)/interimap.1 $(DESTDIR)$(systemd_userunitdir)/interimap*.service rm -vf -- $(DESTDIR)$(bindir)/pullimap $(DESTDIR)$(man1dir)/pullimap.1 $(DESTDIR)$(systemd_userunitdir)/pullimap*.service rm -vf -- $(DESTDIR)$(sitelib)/Net/IMAP/InterIMAP.pm rm -rvf -- $(DESTDIR)$(datarootdir)/doc/interimap $(DESTDIR)$(datarootdir)/doc/pullimap clean: rm -vf -- $(PROGRAMS) $(MANUAL_FILES) $(HTML_FILES) $(SERVICE_FILES) rm -vf -- $(srcdir)/tests/certs/*.key $(srcdir)/tests/certs/*.crt $(srcdir)/tests/certs/*.pem -rmdir -vp --ignore-fail-on-non-empty -- $(builddir)/doc .PHONY: all all-nodoc manual html doc release testcerts \ check check-interimap check-pullimap \ install install-nodoc \ installcheck installcheck-interimap installcheck-pullimap \ uninstall clean interimap-0.5.8/README 0000664 0000000 0000000 00000007632 15003201720 0014372 0 ustar 00root root 0000000 0000000 InterIMAP is a fast bidirectional synchronization program for QRESYNC-capable IMAP4rev1 servers. PullIMAP retrieves messages a remote IMAP mailbox and deliver them to an SMTP session. Visit https://guilhem.org/interimap for more information. ______________________________________________________________________________ Compared to IMAP-to-Maildir synchronization solutions like OfflineIMAP, adding an IMAP server between the Maildir storage and the MUA saves loads of readdir(2) system calls and other File System quirks; moreover the abstraction layer offered by the IMAP server makes the MUA and synchronization program agnostic to the storage backend (Maildir, mbox, dbox,...) in use. IMAP synchronization of a mailbox is usually two-folds: 1/ detect and propagate changes (flag updates and message deletions) to existing messages, then 2/ copy the new messages. The naive way to perform the first step is to issue a FETCH command to list all messages in the mailbox along with their flags and UIDs, causing heavy network usage. Instead, InterIMAP takes advantage of the QRESYNC extension from [RFC7162] to perform stateful synchronization: querying changes since the last synchronization only gives a phenomenal performance boost and drastically reduces the network traffic. For convenience reasons servers must also support LIST-EXTENDED [RFC5258], LIST-STATUS [RFC5819] and UIDPLUS [RFC4315]. Other supported extensions are: * LITERAL+ [RFC2088] non-synchronizing literals (recommended); * MULTIAPPEND [RFC3502] (recommended); * COMPRESS=DEFLATE [RFC4978] (recommended); * SASL-IR [RFC4959] SASL Initial Client Response; and * UNSELECT [RFC3691]. ______________________________________________________________________________ IMAP traffic is mostly text (beside message bodies perhaps) hence compresses pretty well: enabling compression can save a great amount of network resources. However establishing an SSL/TLS connection (type=imaps, or type=imap and STARTTLS=YES) yields a small overhead due to the SSL/TLS handshake. On the other hand if SSH access is allowed on the remote server, one can tunnel the IMAP traffic through SSH and use OpenSSH's ControlPersist feature to save most of the cryptographic overhead (at the expense of a local 'ssh' process and a remote 'imap' process). Moreover if the IMAP user is a valid UNIX user it is possible to use pre-authentication on the remote server as well, which saves the extra round trip caused by the AUTHENTICATE command. For instance the following configuration snippet saves bandwidth and brings a significant speed gain compared to type=imaps. local: $XDG_CONFIG_HOME/interimap/config: [remote] type = tunnel command = /usr/bin/ssh user@imap.example.net local: ~/.ssh/config: Host imap.example.net IdentityFile ~/.ssh/id-interimap IdentitiesOnly yes ControlPath ${XDG_RUNTIME_DIR}/ssh-imap-%C ControlMaster auto ControlPersist 10m StrictHostKeyChecking yes ServerAliveCountMax 3 ServerAliveInterval 10s RequestTTY no Compression yes remote: ~user/.ssh/authorized_keys: restrict,command="/usr/bin/doveadm exec imap" ssh-[…] id-interimap However for long-lived connections (using the --watch command-line option), the TLS overhead becomes negligible hence the advantage offered by the OpenSSH ControlPersist feature is not obvious. Furthermore if the remote server supports the IMAP COMPRESS extension [RFC4978], adding compress=DEFLATE to the configuration can also greatly reduce bandwidth usage with regular INET sockets (type=imaps or type=imap). ______________________________________________________________________________ InterIMAP is Copyright © 2015-2022 Guilhem Moulin ⟨guilhem@fripost.org⟩, and is licensed for use under the GNU General Public License version 3 or later. See ‘COPYING’ for specific terms and distribution information. interimap-0.5.8/benchmark/ 0000775 0000000 0000000 00000000000 15003201720 0015434 5 ustar 00root root 0000000 0000000 interimap-0.5.8/benchmark/dovecot.conf 0000664 0000000 0000000 00000002051 15003201720 0017744 0 ustar 00root root 0000000 0000000 dovecot_config_version = 2.4.0 dovecot_storage_version = 2.4.0 log_path = /dev/shm/mail.log mail_home = /dev/shm/vmail/%{user | username | lower} mail_driver = mdbox mail_path = ~/mail mailbox_list_index = yes ssl = no listen = 127.0.0.1 namespace inbox { inbox = yes separator = / } # https://doc.dovecot.org/latest/core/config/rootless.html#rootless-installation base_dir = /dev/shm/dovecot/run default_internal_user = nobody default_internal_group = nogroup default_login_user = nobody service anvil { chroot = } service imap-login { chroot = } service stats { chroot = } passdb passwd-file { driver = passwd-file default_password_scheme = plain passwd_file_path = /dev/shm/dovecot/users } userdb passwd-file { driver = passwd-file passwd_file_path = /dev/shm/dovecot/users } protocols { imap = yes } service imap-login { inet_listener imap { # disable port = 0 } inet_listener interimap { port = 10143 } inet_listener offlineimap { port = 10144 } } interimap-0.5.8/benchmark/random_maildir.pl 0000775 0000000 0000000 00000003734 15003201720 0020764 0 ustar 00root root 0000000 0000000 #!/usr/bin/perl #---------------------------------------------------------------------- # Generate a random mbox # Copyright © 2019 Guilhem Moulin # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . #---------------------------------------------------------------------- use warnings; use strict; use Time::HiRes (); use POSIX qw{strftime setlocale}; use Crypt::URandom "urandom"; setlocale(POSIX::LC_TIME, "C"); foreach (qw/cur new tmp/) { my $d = $ARGV[0] ."/". $_; mkdir $d, 0700 or die "mkdir: $!" } my $NEW = $ARGV[0] ."/new"; for (my $i = 0; $i < $ARGV[1]; $i++) { my $timestamp = Time::HiRes::gettimeofday(); my $filename = sprintf("%10d.M%010d.localhost", $timestamp, $i); open my $out, ">", $NEW ."/". $filename or die "open: $!"; my $message_id = sprintf "%.10f\@example.net", $timestamp; my $sender = unpack("H*", urandom(8))."\@example.net"; print $out "From: <$sender>\r\n" . "To: \r\n" . "Subject: Hello world! \r\n" . "Date: ". strftime("%a, %e %b %Y %H:%M:%S %z", localtime($timestamp)). "\r\n" . "Message-ID: ". sprintf("<%.10f\@example.net>", $timestamp)."\r\n" . "\r\n"; my $len = 1 + int(rand(4095)); my $body = unpack("H*", urandom($len)); for (my $i = 0; $i < 2*$len; $i += 70) { print $out substr($body, $i, 70), "\r\n"; } close($out); } interimap-0.5.8/benchmark/run 0000775 0000000 0000000 00000036072 15003201720 0016176 0 ustar 00root root 0000000 0000000 #!/bin/bash set -ue PATH="/usr/sbin:/usr/bin:/sbin:/bin" export PATH unset CHROOT NETNS cleanup() { if [ "${CHROOT+x}" ]; then schroot --end-session --chroot="$CHROOT" fi if [ "${NETNS+x}" ]; then ip netns del "$NETNS" fi } trap cleanup EXIT INT TERM # create new CHROOT DEB_BUILD_ARCH="$(dpkg-architecture -qDEB_BUILD_ARCH)" CHROOT="$(schroot -c "unstable-$DEB_BUILD_ARCH-sbuild" -b)" ROOTDIR="/run/schroot/mount/$CHROOT" # create new network namespace and place counters to measure network usage ip netns add "${NETNS:="interimap-benchmark"}" ip netns exec "$NETNS" nft -f- <<- EOF flush ruleset table inet filter { counter interimap-in { } counter interimap-out { } counter offlineimap-in { } counter offlineimap-out { } chain input { type filter hook input priority 0 iif "lo" ip daddr 127.0.0.1 tcp dport 10143 counter name interimap-in iif "lo" ip daddr 127.0.0.1 tcp dport 10144 counter name offlineimap-in } chain output { type filter hook output priority 0 oif "lo" ip saddr 127.0.0.1 tcp sport 10143 counter name interimap-out oif "lo" ip saddr 127.0.0.1 tcp sport 10144 counter name offlineimap-out } } EOF ip netns exec "$NETNS" ip addr add "127.0.0.1" dev "lo" ip netns exec "$NETNS" ip link set "lo" up # resize the partition so it can hold the mail stores (you may want to # turn swap off too) mount -o"remount,size=15G" "/run/schroot/mount/$CHROOT/dev/shm" # install dependencies schroot --directory="/" --chroot="$CHROOT" -r -- \ env DEBIAN_FRONTEND="noninteractive" apt-get install \ --no-install-recommends --assume-yes \ time dovecot-imapd offlineimap \ libconfig-tiny-perl libdbd-sqlite3-perl libnet-ssleay-perl \ libcrypt-urandom-perl procps # run a command in the chroot jail() { local user="" home="" case "${u:-local}" in local) user="nobody"; home="/dev/shm/nobody";; remote) user="user"; home="/dev/shm/vmail/user";; esac ip netns exec "$NETNS" \ schroot --directory="/dev/shm/nobody" --user="nobody" --chroot="$CHROOT" -r \ -- env -i PATH="/usr/bin:/bin" USER="$user" HOME="$home" "$@" } # run a command in the chroot in a monitored fashion jail_stat() { local e U S M P u="local" local counters="$ROOTDIR/tmp/counters.$1.json" ip netns exec "$NETNS" nft reset counter inet filter "$1-in" >/dev/null ip netns exec "$NETNS" nft reset counter inet filter "$1-out" >/dev/null jail time --format="%e\\t%U\\t%S\\t%M\\t%P" --output="/tmp/time.$1" \ -- "$@" >/dev/null || true IFS=$'\t' read e U S M P <"$ROOTDIR/tmp/time.$1" local i="${NAME:-$1}" local a="${i#* }" [ "$a" = "$i" ] && a=" " || a=" $a" printf "%11s%s" "${i%% *}" "$a" printf " %5.2fs %6.2fs" "$U" "$S" if [ "${IDLE:-n}" = "n" ]; then printf " %5.2fs %4s" "$e" "$P" fi printf " %8s" "${M}k" ip netns exec "$NETNS" nft -j list counters | jq ".nftables | map(select(.counter) | .counter | { key: .name, value: {packets, bytes} }) | from_entries" >"$counters" local ib ip ob op ib="$(bytes "$(jq ".\"$1-in\".bytes" <"$counters")")" ob="$(bytes "$(jq ".\"$1-out\".bytes" <"$counters")")" ip="$(_units "$(jq ".\"$1-in\".packets" <"$counters")" 1000)" op="$(_units "$(jq ".\"$1-out\".packets" <"$counters")" 1000)" printf " %8s / %-7s" "$ob" "$ib" # inverse for the client's perspective printf " %8s / %-7s" "$op" "$ip" printf "\\n" } # display metrics headers headers() { declare -a h=(" user" " system") if [ "${IDLE:-n}" = "n" ]; then h+=( " real" " CPU" ) fi h+=( " max RSS" " traffic (in/out) " " packets (in/out) " ) local x="offlineimap -q" i printf "%s" "${x//?/ }" for i in "${h[@]}"; do printf " %s" "$i" done printf "\\n" printf "%s" "${x//?/-}" for i in "${h[@]}"; do printf " %s" "${i//?/-}" done printf "\\n" } # install Dovecot's "system" configuration and start the server install -onobody -gnogroup -m0700 --directory \ "$ROOTDIR/dev/shm/dovecot" \ "$ROOTDIR/dev/shm/vmail" \ "$ROOTDIR/dev/shm/nobody" install -onobody -gnogroup -m0644 \ "./benchmark/dovecot.conf" \ "$ROOTDIR/dev/shm/dovecot/config" jail /usr/sbin/dovecot -c"/dev/shm/dovecot/config" install -onobody -gnogroup -m0600 /dev/null \ "$ROOTDIR/dev/shm/dovecot/users" PASSWORD="$(xxd -l16 -p "$ROOTDIR/dev/shm/dovecot/users" # install user configuration for Dovecot, interimap, and offlineimap cat >"$ROOTDIR/dev/shm/nobody/.dovecot.conf" <<-EOF dovecot_config_version = 2.4.0 dovecot_storage_version = 2.4.0 log_path = /dev/null ssl = no mail_home = /dev/shm/nobody mail_driver = maildir mail_path = ~/Maildir mailbox_list_index = yes EOF install -onobody -gnogroup -Dm0700 --directory \ "$ROOTDIR/dev/shm/nobody/.config/interimap" \ "$ROOTDIR/dev/shm/nobody/.local/share" cat >"$ROOTDIR/dev/shm/nobody/.config/interimap/config" <<-EOF database = bench.db [local] type = tunnel command = doveadm -c/dev/shm/nobody/.dovecot.conf exec imap null-stderr = YES [remote] type = imap host = 127.0.0.1 port = 10143 STARTTLS = no username = user password = $PASSWORD EOF cat >"$ROOTDIR/dev/shm/nobody/.offlineimaprc" <<-EOF [general] accounts = bench [Account bench] localrepository = local remoterepository = remote [Repository local] type = Maildir localfolders = ~/Maildir2 [Repository remote] type = IMAP remotehost = 127.0.0.1 remotepass = $PASSWORD remoteport = 10144 remoteuser = user ssl = no starttls = no # keep the default (no) as it doesn't seem to work with large mailboxes, perhaps # due to https://dovecot.org/pipermail/dovecot/2019-November/117522.html #usecompression = yes EOF # install interimap's development version install -oroot -groot -m0755 -Dt "$ROOTDIR/usr/bin" \ ./interimap ./benchmark/random_maildir.pl sed -ri "0,/^(use\\s+\lib\\s+)([\"'])[^\"']*\\2\\s*;/ s||#&|" -- "$ROOTDIR/usr/bin/interimap" install -oroot -groot -m0644 -DT \ ./lib/Net/IMAP/InterIMAP.pm "$ROOTDIR/usr/share/perl5/Net/IMAP/InterIMAP.pm" # create a random mail store at mdbox:~/mail.back prepare() { local u="remote" d m n seqs local maildir="/dev/shm/vmail/user/maildir" clear jail rm -rf -- "$maildir" "/dev/shm/vmail/user/mail.back" for m in "${!MAILBOXES[@]}"; do [ "${m^^[a-z]}" = "INBOX" ] && d="$maildir" || d="$maildir/.$m" jail mkdir -p -- "$d" # create 20% more; will be deleted afterwards (having only # contiguous UIDs might bias the metrics) n="${MAILBOXES["$m"]}" jail random_maildir.pl "$d" $((n+n/5)) done # convert to mdbox jail doveadm -c"/dev/shm/dovecot/config" -omail_driver="maildir" -omail_path="~/maildir" \ sync --no-userdb-lookup "mdbox:~/mail.back" jail rm -rf -- "$maildir" # expunge 20% and purge for m in "${!MAILBOXES[@]}"; do n="${MAILBOXES["$m"]}" seqs="$(shuf -n $((n/5)) -i"1-$n")" jail doveadm -c"/dev/shm/dovecot/config" -omail_driver="mdbox" -omail_path="~/mail.back" \ expunge --no-userdb-lookup mailbox "$m" "${seqs//$'\n'/,}" done jail doveadm -c"/dev/shm/dovecot/config" -omail_driver="mdbox" -omail_path="~/mail.back" \ purge --no-userdb-lookup } # populate a clientn from backup mailstore mdbox:~/mail.back (copied to # avoid recreating / conversion) populate() { local m u="remote" cmd clear if [ "${KEEP_BACKUP:-y}" = "n" ]; then jail mv -T "/dev/shm/vmail/user/mail.back" "/dev/shm/vmail/user/mail" else jail cp -aT "/dev/shm/vmail/user/mail.back" "/dev/shm/vmail/user/mail" fi # force dovecot to index and compute the state, otherwise the first # thing to query might be disadvantaged jail doveadm -c"/dev/shm/dovecot/config" index --no-userdb-lookup "INBOX" 2>/dev/null jail doveadm -c"/dev/shm/dovecot/config" mailbox status --no-userdb-lookup "all" "*" >/dev/null u="local" # initial configuration for cmd in "$@"; do case "$cmd" in interimap) jail interimap --quiet 2>/dev/null;; offlineimap) jail offlineimap -u quiet 2>/dev/null;; *) exit 1;; esac done } # remove interimap / offlineimap database and mail store (but keep # mdbox:~/mail.back) clear() { jail rm -rf -- \ "/dev/shm/vmail/user/mail" \ "/dev/shm/nobody/.local/share/interimap/bench.db" \ "/dev/shm/nobody/.offlineimap" \ "/dev/shm/nobody/Maildir" \ "/dev/shm/nobody/Maildir2" } # pretty print a number in k/M/G/T etc _units() { local n=$(( $1 )) b="$2" s u="" [ $b -eq 1024 ] && s="i" || s="" while [ ${#n} -gt 4 ]; do case "$u" in "") u="k";; k) u="M";; M) u="G";; G) u="T";; *) break;; esac n=$((n/b)) done printf "%d%s" "$n" "${u:+$u$s}" } bytes() { printf "%sB" "$(_units "$1" 1024)"; } # generate and deliver a random message sample_message() { local date="$(date +"%s.%N")" cat <<-EOF From: To: Date: $(date -R -d@"$date") Message-ID: <$date@example.net> EOF xxd -ps -c30 -l2048 /dev/urandom # 4165 bytes } deliver() { local m="$1" u="remote" jail doveadm -c"/dev/shm/dovecot/config" exec dovecot-lda -e -m "$m" } # write down markdown title title() { local x="$1" h="$2" printf "\\n%s\\n%s\\n" "$h" "${h//?/$x}" } # run benchmark for `interimap` / `offlineimap -q` / `offlineimap`: # populate, run optional actions (such as delivery), then sync again in # a monitored fashion run-all() { local a cmd u q="" NAME="" for cmd in "interimap" "offlineimap -q" "offlineimap"; do populate "${cmd%% *}" case "${cmd%% *}" in interimap) q="--quiet";; offlineimap) q="-u quiet";; *) exit 1; esac for a in "$@"; do "$a"; done NAME="$cmd" jail_stat $cmd $q 2>/dev/null done } echo; echo title "=" "Single mailbox" cat <<-EOF We create a mailbox on the remote server, populate it with a number of messages, and synchronize it locally. We then collect metrics for no-op synchronization (i.e., of mailboxes that are already in sync), and reconciliation after receiving a *single* message on the remote server. EOF # generate a message to be used in *all* "Single mailbox" tests sample_message >"$ROOTDIR/tmp/msg1" activity1() { deliver "inbox" <"$ROOTDIR/tmp/msg1" } declare -A MAILBOXES for n in 100 1000 10000 100000; do title "-" "$n messages" MAILBOXES=( ["inbox"]="$n" ) prepare printf "\\n### %s ###\\n\\n" "No-op (in sync)" headers run-all printf "\\n### %s ###\\n\\n" "Reconciliation" headers run-all activity1 done m=75 echo; echo title "=" "$m mailboxes" cat <<-EOF We create $m mailboxes on the remote server, populate them with an equal number of messages, and synchronize them locally. We then collect metrics for no-op synchronization (i.e., of mailboxes that are already in sync), and reconciliation after the following changes are being applied to the remote server: - 3 *new* messages (two on mailbox #2, one on mailbox #3); and - 5 existing messages *EXPUNGEd* (two on mailboxes #3 and #4, one on mailbox #5). EOF # generate more messages to be used in *all* "$m mailboxes" tests sample_message >"$ROOTDIR/tmp/msg2" sample_message >"$ROOTDIR/tmp/msg3" activity2() { local u="remote" deliver "mailbox2" <"$ROOTDIR/tmp/msg1" deliver "mailbox2" <"$ROOTDIR/tmp/msg2" deliver "mailbox3" <"$ROOTDIR/tmp/msg3" # intentionally modify the remote only because not all local backend speak IMAP jail doveadm -c"/dev/shm/dovecot/config" expunge --no-userdb-lookup mailbox "mailbox3" "1:2" jail doveadm -c"/dev/shm/dovecot/config" expunge --no-userdb-lookup mailbox "mailbox4" "1,3" jail doveadm -c"/dev/shm/dovecot/config" expunge --no-userdb-lookup mailbox "mailbox5" "*" } for n in 100 1000 10000; do title "-" "$n messages per mailbox" MAILBOXES=( ["inbox"]="$n" ) for ((i=2; i<=$m; i++)); do MAILBOXES["mailbox$i"]="$n" done prepare printf "\\n### %s ###\\n\\n" "No-op (in sync)" headers run-all printf "\\n### %s ###\\n\\n" "Reconciliation" headers run-all activity2 done title "=" "Live synchronization" timeout=$((6 * 3600)) step=5 MAILBOXES=( ["inbox"]=100000 ["xlarge"]=100000 ) for ((i=0; i<10; i++)); do MAILBOXES["large$i"]=10000 done for ((i=0; i<20; i++)); do MAILBOXES["medium$i"]=5000 done for ((i=0; i<45; i++)); do MAILBOXES["small$i"]=2000 done for ((i=0; i<20; i++)); do MAILBOXES["xsmall$i"]=500 done n=0 for i in "${MAILBOXES[@]}"; do n=$(( n + i )) done cat <<-EOF ${#MAILBOXES[@]} mailboxes, $n messages in total: - 2 with 100000 messages; - 10 with 10000 messages; - 20 with 5000 messages; - 45 with 2000 messages; and - 20 with 500 messages. The two local mail stores (respectively for [InterIMAP] and [OfflineIMAP]) are initially in sync with the remote server, and we keep long-running “autorefresh” synchronization processes alive for 6h, with updates being regularly applied to the remote server: every $step seconds, - a new message is delivered to a random mailbox with 5% probability (once every $((20*step))s on average); - a random message is EXPUNGEd with 5% probability (once every $((20*step))s on average); and - a random message is marked as seen with 10% probability (once every $((10*step))s on average). \`interimap\` is configured to sync every *30s*. \`offlineimap\` is configured to quick sync very *30s*, with a regular sync every *1h*. EOF IDLE="y" headers prepare KEEP_BACKUP="n" populate "interimap" "offlineimap" IDLE="y" jail_stat interimap --quiet --watch=30 2>/dev/null & IDLE="y" jail_stat offlineimap -u quiet -k "Account_bench:autorefresh=0.5" \ -k "Account_bench:quick=120" 2>/dev/null & u="remote" timeout=$(( $(date +%s) + timeout )) while [ $(date +%s) -lt $timeout ]; do n="$(shuf -n1 -i1-100)" if [ $n -le 5 ]; then # deliver to a random mailbox on the remote m="$(shuf -n1 -e -- "${!MAILBOXES[@]}")" sample_message | deliver "$m" fi n="$(shuf -n1 -i1-100)" if [ $n -le 5 ]; then # expunge a random message on the remote read guid uid < <(jail doveadm -c"/dev/shm/dovecot/config" search --no-userdb-lookup all | shuf -n1) jail doveadm -c"/dev/shm/dovecot/config" expunge --no-userdb-lookup mailbox-guid "$guid" uid "$uid" fi n="$(shuf -n1 -i1-100)" if [ $n -le 10 ]; then # mark a random message as seen read guid uid < <(jail doveadm -c"/dev/shm/dovecot/config" search --no-userdb-lookup all | shuf -n1) jail doveadm -c"/dev/shm/dovecot/config" flags add --no-userdb-lookup "\\Seen" mailbox-guid "$guid" uid "$uid" fi sleep $step done jail pkill -TERM -u"nobody" -s0 interimap sleep 0.2 # give a chance to print the stats jail pkill -SIGABRT -u"nobody" -s0 offlineimap wait interimap-0.5.8/doc/ 0000775 0000000 0000000 00000000000 15003201720 0014247 5 ustar 00root root 0000000 0000000 interimap-0.5.8/doc/benchmark.md 0000664 0000000 0000000 00000034520 15003201720 0016527 0 ustar 00root root 0000000 0000000 % InterIMAP benchmark metrics and comparison % [Guilhem Moulin](mailto:guilhem@fripost.org) The [IMAP `QRESYNC` extension][RFC 7162] allows efficient mailbox synchronization, in terms of I/O as well as CPU usage. In this document we give some benchmark metrics to compare [InterIMAP]'s network usage with so-called full synchronization solutions such as [OfflineIMAP]. The timings are to be taken with a grain of salt, though: they likely won't reflect real-world situations as the emails are stored in RAM for this benchmark, and all network access is on the loopback interface. (Moreover neither SSL/TLS nor STARTTLS are being used in the below. They would add another 2-3 round-trips per connection.) These metrics show how [InterIMAP] scales linearly with the number of *mailboxes* — pretty much regardless of how many messages they contain (at least as long as the server can cope with large mailboxes) — while [OfflineIMAP] scales with the number of *messages* on active mailboxes. While [InterIMAP] performs significantly better (especially given that it can be relied upon to synchronize flag changes, unlike [OfflineIMAP]'s “quick” mode), it should be noted that efficiency comes at the expense of flexibility. In particular it's not possible to exclude old messages from synchronization (mailboxes can be excluded but finer granularity is not possible). And of course not all IMAP servers support [`QRESYNC`][RFC 7162] and other extensions [InterIMAP] requires. Furthermore [InterIMAP] is single threaded and doesn't use pipelining at the moment. (Concurrency opens a can of worms, and given the below metrics it simply doesn't seem worth the trouble ☺) ----------------------------------------------------------------------- The script used to compute these metrics can be found [there][benchmark-script]. We use [Dovecot] as IMAP server; the “remote” mailbox store is in [multi-dbox][dbox] format (initially populated with random messages of average size ~4kiB, and randomly pruned to avoid having only contiguous UIDs) while [maildir] is used “locally”. The configuration files were not tuned for performance (however [InterIMAP] takes advantage of Dovecot's support of the [IMAP `COMPRESS` extension][RFC 4978] as it is its default behavior). The *user* (resp. *system*) column denotes the number of CPU-seconds used by the process in user (resp. kernel) mode. The *real* column is the elapsed real (wall clock) time. Network measurements are obtained by placing packet counters on the interface. [RFC 4978]: https://tools.ietf.org/html/rfc4978 [RFC 7162]: https://tools.ietf.org/html/rfc7162 [InterIMAP]: interimap.1.html [OfflineIMAP]: https://www.offlineimap.org/ [benchmark-script]: https://git.guilhem.org/interimap/plain/benchmark/run [Dovecot]: https://dovecot.org [dbox]: https://doc.dovecot.org/latest/core/config/mailbox_formats/dbox.html [maildir]: https://doc.dovecot.org/latest/core/config/mailbox_formats/maildir.html ----------------------------------------------------------------------- Single mailbox {#single-mailbox} ============== We create a mailbox on the remote server, populate it with a number of messages, and synchronize it locally. We then collect metrics for no-op synchronization (i.e., of mailboxes that are already in sync), and reconciliation after receiving a *single* message on the remote server. [OfflineIMAP]'s network usage remains low in “quick” mode for large mailboxes that are already in sync, but as soon as a mail arrives the performance degrades by *several orders of magnitude*. On the other hand [InterIMAP] has very little overhead on large mailboxes (also memory-wise), and when a message is delivered there is barely more traffic than what's required for the transfer of said message. 100 messages ------------ ### No-op (in sync) ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.05s 0.01s 0.07s 85% 21368k 1439B / 1017B 13 / 15 offlineimap -q 0.04s 0.01s 0.27s 23% 19748k 2497B / 1236B 16 / 20 offlineimap 0.05s 0.01s 0.32s 22% 19268k 10kiB / 1456B 21 / 23 ### Reconciliation ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.06s 0.00s 0.08s 83% 21116k 4516B / 1412B 17 / 19 offlineimap -q 0.06s 0.00s 0.32s 22% 19968k 15kiB / 1670B 23 / 26 offlineimap 0.06s 0.00s 0.32s 22% 18616k 14kiB / 1284B 25 / 19 1000 messages ------------- ### No-op (in sync) ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.05s 0.01s 0.07s 84% 21204k 1449B / 965B 13 / 14 offlineimap -q 0.06s 0.01s 0.33s 24% 19068k 2664B / 1236B 19 / 20 offlineimap 0.09s 0.02s 0.37s 30% 19868k 75kiB / 1508B 26 / 24 ### Reconciliation ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.06s 0.00s 0.08s 78% 21212k 4524B / 1333B 17 / 16 offlineimap -q 0.08s 0.03s 0.33s 37% 22284k 80kiB / 1775B 29 / 28 offlineimap 0.10s 0.01s 0.32s 36% 20116k 80kiB / 1597B 24 / 25 10000 messages -------------- ### No-op (in sync) ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.06s 0.00s 0.09s 75% 20980k 1449B / 965B 13 / 14 offlineimap -q 0.10s 0.03s 0.37s 37% 36708k 2719B / 1184B 20 / 19 offlineimap 0.50s 0.09s 0.78s 75% 45424k 746kiB / 2080B 37 / 35 ### Reconciliation ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.06s 0.00s 0.12s 54% 21136k 4530B / 1205B 17 / 16 offlineimap -q 0.51s 0.08s 0.76s 77% 42860k 751kiB / 2608B 43 / 44 offlineimap 0.62s 0.16s 0.88s 89% 47996k 750kiB / 2222B 38 / 37 100000 messages --------------- ### No-op (in sync) ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.06s 0.00s 0.16s 38% 21080k 1441B / 1017B 13 / 15 offlineimap -q 1.06s 0.10s 1.40s 83% 201376k 2722B / 1236B 20 / 20 offlineimap 4.88s 0.83s 5.23s 109% 280716k 7626kiB / 5564B 138 / 102 ### Reconciliation ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.06s 0.00s 0.48s 15% 22876k 4532B / 1362B 17 / 19 offlineimap -q 5.09s 0.75s 5.38s 108% 277336k 7637kiB / 9941B 261 / 185 offlineimap 4.92s 0.76s 5.22s 108% 279592k 7631kiB / 5603B 144 / 102 ----------------------------------------------------------------------- 75 mailboxes {#multi-mailbox} ============ We create 75 mailboxes on the remote server, populate them with an equal number of messages, and synchronize them locally. We then collect metrics for no-op synchronization (i.e., of mailboxes that are already in sync), and reconciliation after the following changes are being applied to the remote server: - 3 *new* messages (two on mailbox #2, one on mailbox #3); and - 5 existing messages *EXPUNGEd* (two on mailboxes #3 and #4, one on mailbox #5). The results are not surprising given the metrics from the [above section](#single-mailbox). In “quick” mode [OfflineIMAP] still performs reasonably well when the mailboxes are in sync (even though it iterates through each mailbox and the extra roundtrips increase network traffic compared to the single mailbox case), but performance decrease significantly when a message is delivered to a large mailbox. Once again [InterIMAP] has very little network overhead regardless of mailbox size; it does take longer on very large mailboxes, but the bottleneck is the IMAP server ([InterIMAP] is just rolling thumbs waiting for Dovecot to compute `STATUS` responses). 100 messages per mailbox ------------------------ ### No-op (in sync) ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.06s 0.00s 0.12s 55% 21712k 1949B / 898B 11 / 13 offlineimap -q 0.32s 0.08s 0.43s 92% 22400k 36kiB / 7260B 93 / 99 offlineimap 0.97s 0.32s 1.32s 98% 22648k 606kiB / 19kiB 243 / 251 ### Reconciliation ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.07s 0.00s 0.15s 53% 21860k 10kiB / 1634B 19 / 19 offlineimap -q 0.34s 0.11s 0.59s 77% 21248k 81kiB / 8697B 109 / 117 offlineimap 0.93s 0.35s 1.30s 98% 22804k 620kiB / 20kiB 252 / 253 1000 messages per mailbox ------------------------- ### No-op (in sync) ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.05s 0.01s 0.31s 22% 22028k 1944B / 898B 11 / 13 offlineimap -q 0.97s 0.22s 1.22s 97% 23920k 36kiB / 7000B 90 / 94 offlineimap 4.87s 1.54s 5.01s 127% 25040k 5507kiB / 26kiB 393 / 388 ### Reconciliation ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.08s 0.00s 0.29s 28% 22132k 10kiB / 1931B 20 / 19 offlineimap -q 1.25s 0.32s 1.45s 108% 27276k 344kiB / 9038B 119 / 123 offlineimap 4.72s 1.70s 5.05s 127% 26464k 5521kiB / 27kiB 399 / 392 10000 messages per mailbox -------------------------- ### No-op (in sync) ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.07s 0.00s 1.57s 4% 21896k 1942B / 898B 11 / 13 offlineimap -q 12.10s 3.98s 11.67s 137% 58624k 37kiB / 10kiB 94 / 168 offlineimap 55.49s 23.68s 51.50s 153% 70652k 54MiB / 57kiB 1072 / 996 ### Reconciliation ### user system real CPU max RSS traffic (in/out) packets (in/out) -------------- ------ ------- ------ ---- -------- ------------------ ------------------ interimap 0.08s 0.00s 1.73s 5% 23108k 10kiB / 1624B 20 / 23 offlineimap -q 14.60s 5.22s 14.00s 141% 64988k 3028kiB / 15kiB 203 / 263 offlineimap 57.24s 25.92s 53.72s 154% 76560k 54MiB / 89kiB 1981 / 1625 ----------------------------------------------------------------------- Live synchronization {#live-sync} ==================== 97 mailboxes, 500000 messages in total: - 2 with 100000 messages; - 10 with 10000 messages; - 20 with 5000 messages; - 45 with 2000 messages; and - 20 with 500 messages. The two local mail stores (respectively for [InterIMAP] and [OfflineIMAP]) are initially in sync with the remote server, and we keep long-running “autorefresh” synchronization processes alive for 6h, with updates being regularly applied to the remote server: every 5 seconds, - a new message is delivered to a random mailbox with 5% probability (once every 100s on average); - a random message is EXPUNGEd with 5% probability (once every 100s on average); and - a random message is marked as seen with 10% probability (once every 50s on average). `interimap` is configured to sync every *30s*. `offlineimap` is configured to quick sync very *30s*, with a regular sync every *1h*. user system max RSS traffic (in/out) packets (in/out) ----------- -------- -------- -------- ------------------ ------------------ interimap 12.95s 0.26s 24276k 743kiB / 257kiB 2207 / 4143 offlineimap 5327.79s 1495.78s 394044k 942MiB / 7840kiB 87k / 126k Long-lived synchronization for large and busy mail stores is where [InterIMAP] truly shines, in terms of CPU as well as network usage. (The amount of CPU time spent in kernel mode is so low because the process spends most of its time sleeping or in blocking calls waiting for the server to compute `STATUS` responses. Smart servers like Dovecot should cache states though, hence are able to serve these responses quickly.) Thanks to the [`QRESYNC`][RFC 7162]-based synchronization there is no need for complex client-side computation, nor for sending vast amount of data over the network. (To be fair, while the amount of CPU time spent in user mode remains low, the local IMAP server might do a bit of extra work which is not counted here. But here again caching helps avoid expensive directory traversal.) The performance gain is most appreciated for battery-powered devices, as well as devices behind slow and/or high-latency network connections ☺. Moreover [InterIMAP] *does* synchronize flag updates at every step, while [OfflineIMAP] normally skips these in “quick” mode so might *delay* flag updates for up to one hour. interimap-0.5.8/doc/build.md 0000664 0000000 0000000 00000010761 15003201720 0015675 0 ustar 00root root 0000000 0000000 % Build instructions % [Guilhem Moulin](mailto:guilhem@fripost.org) On Debian 10 (codename *Buster*) and later, installing [`interimap`(1)] is a single command away: $ sudo apt install interimap This document is for those who are running other systems, and/or who wish to install from [source](https://git.guilhem.org/interimap). Dependencies ============ [`interimap`(1)](interimap.1.html) depends on Perl ≥5.20 and the following Perl modules: * [`Compress::Raw::Zlib`](https://perldoc.perl.org/Compress/Raw/Zlib.html) (*core module*) * [`Config::Tiny`](https://metacpan.org/pod/Config::Tiny) * [`DBI`](https://metacpan.org/pod/DBI) * [`DBD::SQLite`](https://metacpan.org/pod/DBD::SQLite) * [`Errno`](https://perldoc.perl.org/Errno.html) (*core module*) * [`Getopt::Long`](https://perldoc.perl.org/Getopt/Long.html) (*core module*) * [`MIME::Base64`](https://perldoc.perl.org/MIME/Base64.html) (*core module*) — if authentication is required * [`List::Util`](https://perldoc.perl.org/List/Util.html) (*core module*) * [`Net::SSLeay`](https://metacpan.org/pod/Net::SSLeay) ≥1.86_06 * [`POSIX`](https://perldoc.perl.org/POSIX.html) (*core module*) * [`Socket`](https://perldoc.perl.org/Socket.html) (*core module*) * [`Time::HiRes`](https://perldoc.perl.org/Time/HiRes.html) (*core module*) — if `logfile` is set On Debian GNU/Linux systems, the dependencies can be installed with the following command: $ sudo apt install libconfig-tiny-perl libdbd-sqlite3-perl libnet-ssleay-perl Additional packages are required in order to run the test suite: $ sudo apt install dovecot-imapd dovecot-lmtpd openssl procps sqlite3 xxd $ make check (Note also that the test suite needs to bind to TCP ports 10024, 10143 and 10993 on the loopback interface.) Generate documentation ====================== Yet another set of packages is needed to generate the documentation: $ sudo apt install jq pandoc Run `` `make manual` `` (or just `` `make` ``) in order to generate the manuals. You'll find them at `build/doc/*.[1-9]`. Then use for instance `` `man -l build/doc/interimap.1` `` to read your local copy of the [`interimap`(1)] manual. The HTML documentation can be built with `` `make html` ``. By default HTML files are generated at `build/doc/*.html`, and a local CSS file is used — namely `/usr/share/javascript/bootstrap4/css/bootstrap.css`, shipped the by [`libjs-bootstrap`](https://tracker.debian.org/libjs-bootstrap) package. But this is configurable: use for instance $ make html builddir="$XDG_RUNTIME_DIR/interimap" \ CSS="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css" to generate the HTML documentation under `$XDG_RUNTIME_DIR/interimap/doc` using a remote CSS file. The `doc` target generates all documentation, manuals as well as HTML pages. Installation without root privileges ==================================== By default `` `make install` `` installs [`interimap`(1)] under `/usr/local`, hence requires root privileges. However another prefix can be used in order to perform the (un)installation as an unprivileged user. For instance $ install -m0700 -vd ${XDG_DATA_HOME:-~/.local/share}/interimap $ make install-nodoc \ prefix=$HOME/.local \ sitelib=${XDG_DATA_HOME:-~/.local/share}/interimap/lib \ systemd_userunitdir=${XDG_DATA_HOME:-~/.local/share}/systemd/user skips documentation and installs * executables into `~/.local/bin` (instead of `/usr/local/bin`); * libraries into `$XDG_DATA_HOME/interimap/lib` or `~/.local/share/interimap/lib` (instead of `/usr/local/lib/site_perl`); and * [systemd user unit files][`systemd.unit`(5)] into `$XDG_DATA_HOME/systemd/user` or `~/.local/share/systemd/user` (instead of `/usr/local/lib/systemd/user`). Note that for uninstallation one must call `` `make uninstall prefix=…` `` with the very same assignment(s) used for installation. [`systemd.unit`(5)]: https://www.freedesktop.org/software/systemd/man/systemd.unit.html Build custom Debian packages ============================ Debian GNU/Linux users can also use [`gbp`(1)] from [`git-buildpackage`](https://tracker.debian.org/pkg/git-buildpackage) in order to build their own packages: $ git checkout debian/latest $ gbp buildpackage Alternatively, for the development version: $ git checkout debian/latest $ git merge master $ gbp buildpackage --git-force-create --git-upstream-tree=BRANCH [`interimap`(1)]: interimap.1.html [`gbp`(1)]: https://manpages.debian.org/git-buildpackage/gbp.1.en.html interimap-0.5.8/doc/development.md 0000664 0000000 0000000 00000017060 15003201720 0017117 0 ustar 00root root 0000000 0000000 % Test environment setup % [Guilhem Moulin](mailto:guilhem@fripost.org) Introduction ============ This document describes how to create dummy mail storage for [`interimap`(1)] and/or [`pullimap`(1)] development, using [Dovecot] as [IMAP4rev1] server. Start by creating a new temporary directory: $ BASEDIR="$(mktemp --tmpdir --directory)" Dovecot configuration ===================== Create a file `$BASEDIR/dovecot.conf`, which will be used as configuration for the various Dovecot 2.4 commands (the system configuration will be skipped). $ cat >"$BASEDIR/dovecot.conf" <<-EOF dovecot_config_version = 2.4.0 dovecot_storage_version = 2.4.0 log_path = "$BASEDIR/dovecot.log" ssl = no mail_home = "$BASEDIR/%{user | username | lower}" mail_driver = maildir mail_path = ~/Mail EOF Here are some details on the above: `log_path` : Dovecot [logs to syslog][Dovecot Logging] by default. It's annoying to clutter syslog with test entries, so instead we make it log to a file under `$BASEDIR`. `mail_home` : Dovecot needs the name of the user to (pre-)authenticate. It is shown in the greeting line, and also used in [settings variables] expansion. Several [`doveadm`(1)] sub-commands have a `-u` (or `-d`) option which can be used to determine the username. To avoid performing the userdb lookup one can pass `--no-userdb-lookup` instead, in which case the username is taken from the `USER` environment variable. Similarly, the user's home directory is used in (`~`- and) [settings variables] expansion. It's taken from the `HOME` environment variable when the `mail_home` setting is left unset in the Dovecot configuration (and not overridden by the [user database][User Databases]. `mail_home` can therefore be left unset if the `HOME` environment variable is consistently set to `$BASEDIR/$USER`. However it's safer to explicitly set it in the configuration file: otherwise a command run in a non-curated environment might mess up with your own mail storage… `mail_driver` : Use the [Maildir] format for mail storage.. `mail_path`: : The user's mail storage resides in a directory `Mail` under their home directory. This is enough if you're fine with the default IMAP hierarchy delimiter (which depends on the mail format) is used, and if you need a single [IMAP namespace][RFC 2342]. For more complex setups you'll need one or more [`namespace {…}` block][Dovecot Namespaces]. Mail storage access =================== Feel free to point a mail client at the dummy mail storage. To start a pre-authenticated [IMAP4rev1] in the test environment for username `testuser`, list mailboxes, and exit, run: $ env -i PATH="/usr/bin:/bin" USER="testuser" \ doveadm -c "$BASEDIR/dovecot.conf" exec imap S: * PREAUTH [CAPABILITY IMAP4rev1 …] Logged in as testuser C: a LIST "" "*" S: * LIST (\HasNoChildren) "." INBOX S: a OK List completed (0.002 + 0.000 + 0.001 secs). C: q LOGOUT S: * BYE Logging out S: q OK Logout completed (0.001 + 0.000 secs). For mailbox (create, delete, rename) and message (add, flag update) manipulation you can use your mail client, the relevant [IMAP4rev1] commands, or simply the [`doveadm`(1)] tools. Here is an example using the latter to create a mailbox `foo`, add a sample message to it, and finally mark it as `\Seen`. $ env -i PATH="/usr/bin:/bin" USER="testuser" \ doveadm -c "$BASEDIR/dovecot.conf" mailbox create --no-userdb-lookup "foo" $ env -i PATH="/usr/bin:/bin" USER="testuser" HOME="$BASEDIR/testuser" \ doveadm -c "$BASEDIR/dovecot.conf" exec dovecot-lda -e -m "foo" <<-EOF From: To: Subject: Hello world! Date: $(date -R) Message-ID: <$( Hello world! EOF $ env -i PATH="/usr/bin:/bin" USER="testuser" \ doveadm -c "$BASEDIR/dovecot.conf" flags add --no-userdb-lookup "\\Seen" mailbox "foo" "*" Normally [`dovecot-lda`(1)][Dovecot LDA] tries to do a userdb lookup in order to determine the user's home directory. Since we didn't configure a user database we need to explicitly set the `HOME` environment variable. InterIMAP configuration and test ================================ In this example the peers to synchronize are sharing the same Dovecot configuration file `$BASEDIR/dovecot.conf`. Of course, it's also possible to use a different configuration on each “server”, for instance in order to specify different hierarchy delimiters, namespaces, or mail storage format. Create an [`interimap`(1)] configuration file to synchronize the `local` and `remote` accounts. $ cat >"$BASEDIR/interimap.conf" <<-EOF database = $BASEDIR/interimap.db [local] type = tunnel command = env -i PATH="$PATH" USER="local" doveadm -c "$BASEDIR/dovecot.conf" exec imap [remote] type = tunnel command = env -i PATH="$PATH" USER="remote" doveadm -c "$BASEDIR/dovecot.conf" exec imap EOF Run [`interimap`(1)] without `--watch` in order to create the database. $ env -i PATH="$PATH" perl -T ./interimap --config="$BASEDIR/interimap.conf" Creating new schema in database file …/interimap.db database: Created mailbox INBOX […] You can now run [`interimap`(1)] with `--watch` set, here to one second to observe synchronization steps early. $ env -i PATH="$PATH" perl -T ./interimap --config="$BASEDIR/interimap.conf" \ --watch=1 --debug Use instructions from the [previous section][Mail storage access] (substituting `testuser` with `local` or `remote`) in order to simulate activity on either end to synchronize. If you run these commands in another shell, then make sure to re-set the `BASEDIR` environment variable! PullIMAP configuration and test =============================== Create a [`pullimap`(1)] configuration file with as section `[foo]`. $ cat >"$BASEDIR/pullimap.conf" <<-EOF [foo] type = tunnel command = env -i PATH="$PATH" USER="testuser" doveadm -c "$BASEDIR/dovecot.conf" exec imap statefile = $BASEDIR/pullimap.foo EOF Run [`pullimap`(1)] without `--idle` in order to create the state file. $ env -i PATH="$PATH" perl -T ./pullimap --config="$BASEDIR/pullimap.conf" \ --no-delivery foo You can now run [`pullimap`(1)] with `--idle` set. $ env -i PATH="$PATH" perl -T ./pullimap --config="$BASEDIR/pullimap.conf" \ --no-delivery --idle --debug foo Use instructions from the [previous section][Mail storage access] in order to simulate activity on the “remote” server (in the relevant mailbox — `INBOX` by default). If you run these commands in another shell, then make sure to re-set the `BASEDIR` environment variable! Cleanup ======= To remove temporary directories and the message they contain, simply recursively remove the directory `$BASEDIR`. $ rm -rf -- "$BASEDIR" [IMAP4rev1]: https://tools.ietf.org/html/rfc3501 [Dovecot]: https://dovecot.org [Dovecot Logging]: https://doc.dovecot.org/latest/core/admin/logging.html#dovecot-logging [Dovecot LDA]: https://doc.dovecot.org/latest/core/config/delivery/lda.html [User Databases]: https://doc.dovecot.org/latest/core/config/auth/userdb.html [Maildir]: https://doc.dovecot.org/latest/core/config/mailbox_formats/maildir.html [RFC 2342]: https://tools.ietf.org/html/rfc2342 [Dovecot Namespaces]: https://doc.dovecot.org/latest/core/config/namespaces.html [`interimap`(1)]: interimap.1.html [`pullimap`(1)]: pullimap.1.html [`doveadm`(1)]: https://doc.dovecot.org/latest/core/man/doveadm.1.html [settings variables]: https://doc.dovecot.org/latest/core/settings/variables.html interimap-0.5.8/doc/getting-started.md 0000664 0000000 0000000 00000026014 15003201720 0017701 0 ustar 00root root 0000000 0000000 % Getting started with InterIMAP % [Guilhem Moulin](mailto:guilhem@fripost.org); [Gustav Eek](mailto:gustav.eek@fripost.org) This document describes the setup of InterIMAP for a rather usual user-case, where messages on a remote IMAP server `imap.example.net` need to be synchronized locally in a bidirectional fashion (changes on either server are replicated on the other one). Local IMAP server ================= Background and rationale ------------------------ On a workstation, one's mail storage is typically found under `~/Maildir` (in [*Maildir* format][Maildir]) or in `/var/mail/$USER` (in [*mbox* format][mbox]). Local mail clients usually access it directly. They also often maintain their own cache in order to speed up message header listing and searches. While most bidirectional synchronization software (such as [OfflineIMAP]) are able to handle a mail storage in Maildir format, *InterIMAP is not*. Instead, InterIMAP needs an [IMAP4rev1] server on *both* peers to synchronize. This may sound like a severe limitation at first, but by seeing both local and remote mail storage through the same “IMAP lens”, InterIMAP is able to take advantage of the abstraction layer and perform significant optimizations, yielding [much faster](benchmark.html) synchronization. *Note*: InterIMAP uses the [Quick Mailbox Resynchronization][RFC 7162] extension for stateful synchronization, hence won't work on IMAP servers that don't advertise [support](https://www.imapwiki.org/Specs#line-68) for that extension. Installing an [IMAP4rev1] server on a single-user workstation may sound overkill, but we argue that most systems, not only servers, come with a [Message Transfer Agent][MTA] preinstalled. Just like one may use `/usr/sbin/sendmail` (or a compatible interface) in order to send mail out, we propose to use an `imap` binary to access them. In order to take full advantage of the abstraction layer and of InterIMAP's optimizations, one should *always* access the mail storage through the local [IMAP4rev1] server and *never directly*. Otherwise the IMAP server will invalidate its cache each time it notices inconsistencies, potentially causing a severe performance hit. (*Or worse*: very likely many [IMAP4rev1] servers are not able to gracefully reconcile cache inconsistencies.) As far as the mail client is concerned, the cost of abstraction seems to be negligible. (*TODO* link to benchmark.) Furthermore, we think that approach is in line with the [Unix philosophy]: the mail client only takes care of the rendering part, leaving the rest to the IMAP server (searches, sorting/threading, as well as storage and caching logic). Installation ------------ While this document focuses on [Dovecot](https://dovecot.org), a popular [IMAP4rev1] server, any other [`QRESYNC`][RFC 7162]-capable server should work equally well. Run the following command to install the Dovecot IMAP server on a Debian GNU/Linux system. $ sudo apt install dovecot-imapd (The leading `$ ` in this document are command-line prompt strings, which are not part of the command themselves.) Configuration ------------- Our [`interimap`(1)] instance will use the `imap` binary from Dovecot's `libexec_dir` in order to access the local mail storage. We assume that the mail client can access it in the same fashion. In other words, that it can spawn a command and use its standard input (resp. output) for [IMAP4rev1] commands (resp. responses). [Mutt] is an example of such a mail client, for which we propose a configuration snippet [below](#mutt-config). Since we don't need the Dovecot services nor master process in this example, we disable them and create a local configuration file under `$XDG_CONFIG_HOME/dovecot`. If you need to keep the system-wise services (for instance because your [MTA] uses the [LMTP server] for mailbox delivery) then don't disable them, and modify Dovecot's system wide configuration instead. Same thing if your mail client isn't able to spawn a command for IMAP communication, and instead insists on connecting to a network socket (in that case you'll even need to configure [user authentication](https://doc.dovecot.org/latest/core/config/auth/overview.html) for the IMAP service, which is out of scope for the present document). Run the following command to terminate and disable the system-wide Dovecot processes. $ sudo systemctl mask --now dovecot.socket dovecot.service Create a new directory `$XDG_CONFIG_HOME/dovecot` holding the local Dovecot 2.4 configuration: $ install -m0700 -vd ${XDG_CONFIG_HOME:-~/.config}/dovecot $ cat >${XDG_CONFIG_HOME:-~/.config}/dovecot/dovecot.conf <<-EOF dovecot_config_version = 2.4.0 dovecot_storage_version = 2.4.0 ssl = no mail_home = /home/%{user | username | lower} mail_driver = maildir mail_path = ~/Mail namespace inbox { inbox = yes separator = / } EOF Some remarks on the above: * SSL/TLS is explicitly turned off in order to avoid warnings when running `` `doveconf -nc ${XDG_CONFIG_HOME:-~/.config}/dovecot/dovecot.conf` ``. * Messages will be stored in Maildir format under `~/Mail`. Ensure the directory is either *empty* or *doesn't exist* before continuing! You may want to choose a different [format](https://doc.dovecot.org/latest/core/config/mailbox_formats/overview.html) here, or simply set `mailbox_list_layout = fs` in order to use a nicer (File System like) Maildir layout. * The `separator` setting defines the IMAP hierarchy delimiter. This is orthogonal to the Maildir layout delimiter, and you can safely change it later (even on an existing mail store). Popular hierarchy delimiters include `/` (slash) and `.` (period). Now test the configuration by starting a pre-authenticated [IMAP4rev1] session and issuing two commands, first `` `LIST "" "*"` `` to recursively list all mailboxes (along with their hierarchy delimiter), then `` `LOGOUT` `` to… log out and exit. (The "`C: `" and "`S: `" prefixes respectively denote client commands and server responses.) $ doveadm -c ${XDG_CONFIG_HOME:-~/.config}/dovecot/dovecot.conf exec imap S: * PREAUTH [CAPABILITY IMAP4rev1 …] Logged in as myuser C: a LIST "" "*" S: * LIST (\HasNoChildren) "/" INBOX S: a OK List completed (0.001 + 0.000 secs). C: q LOGOUT S: * BYE Logging out S: q OK Logout completed (0.001 + 0.000 secs). Create a wrapper under `~/.local/bin` in order to avoid hard-coding the local Dovecot configuration path: $ install -Dm 0755 /dev/stdin ~/.local/bin/dovecot-imap <<-EOF #!/bin/sh set -ue export PATH="/usr/bin:/bin" exec env -i PATH="\$PATH" HOME="\$HOME" USER="\$USER" \\ doveadm -c "\${XDG_CONFIG_HOME:-\$HOME/.config}/dovecot/dovecot.conf" \\ exec imap EOF You can now start a pre-authenticated [IMAP4rev1] session like the one above by simply running `` `~/.local/bin/dovecot-imap` ``. InterIMAP ======== On Debian 10 (codename *Buster*) and later, installing the package is one command away. Simply run the following: $ sudo apt install interimap Create directories for the InterIMAP configuration and data files: $ install -m0700 -vd ${XDG_CONFIG_HOME:-~/.config}/interimap ${XDG_DATA_HOME:-~/.local/share}/interimap Create the configuration file. The included sample file `/usr/share/doc/interimap/interimap.sample` can be used as baseline, but for the sake of clarity we start from an empty file here. $ install -m0600 /dev/null ${XDG_CONFIG_HOME:-~/.config}/interimap/config 1. The file is in [INI format][INI file]. First, set general options in the default section: $ cat >${XDG_CONFIG_HOME:-~/.config}/interimap/config <<-EOF # only consider subscribed mailboxes list-select-opts = SUBSCRIBED #list-mailbox = "*" # ignore the mailbox named 'virtual' and its descendants # WARN: for version 0.4 and earlier it should be ^virtual(?:/|$) ignore-mailbox = ^virtual(?:\x00|$) EOF 2. Next, append a `[local]` section pointing to the wrapper defined above: $ cat >>${XDG_CONFIG_HOME:-~/.config}/interimap/config <<-EOF [local] type = tunnel command = exec ~/.local/bin/dovecot-imap EOF (The command will be passed to `` `/bin/sh -c` `` as it contains the metacharacter `~`. We use the `exec` built-in utility so the shell process doesn't linger around during the IMAP session.) 3. And finally append a `[remote]` section with your account information at `imap.example.net` (adapt the values accordingly): $ cat >>${XDG_CONFIG_HOME:-~/.config}/interimap/config <<-EOF [remote] type = imaps host = imap.example.net username = myname password = xxxxxxxx EOF At this point running `` `interimap` `` should create the database and copy the entire remote mail store locally. (If `~/Mail` was not empty, it will also copy its content remotely, possibly *yielding duplicates*.) This might take a while depending on the volume of messages to synchronize. $ interimap Creating new schema in database file …/imap.example.net.db database: Created mailbox INBOX […] A user unit for systemd is provided. Run the following command to enable and start the service: $ systemctl --user enable --now interimap.service By default the connection to the IMAP servers remains open, and a status update is requested every minute. Thanks to the [`QRESYNC`][RFC 7162] IMAP extension a status update scales linearly with the number of mailboxes (unlike [OfflineIMAP] *not* with the number of messages). And thanks to the `COMPRESS` extension, the typical volume of data exchanged [is rather small](benchmark.html#live-sync). You may even want to override the default settings and reduce the interval between status updates to 20s: $ mkdir -p ${XDG_CONFIG_HOME:-~/.config}/systemd/user/interimap.service.d $ cat >${XDG_CONFIG_HOME:-~/.config}/systemd/user/interimap.service.d/override.conf <<-EOF [Service] ExecStart= ExecStart=/usr/bin/interimap --watch=20 EOF $ systemctl --user daemon-reload $ systemctl --user restart interimap.service Email client configuration ========================== [Mutt] {#mutt-config} ------ Add the following snippet to the configuration file: $ cat >>~/.muttrc <<-EOF set tunnel = "exec ~/.local/bin/dovecot-imap" set folder = "imap://foo" set spoolfile = "imap://foo" EOF Further Reading and Resources ============================= Other use-cases: : [Multi-remote setup](multi-account.html) Benchmarks: : [Benchmark metrics and comparison](benchmark.html) Manual : [`interimap`(1)] [IMAP4rev1]: https://tools.ietf.org/html/rfc3501 [INI file]: https://en.wikipedia.org/wiki/INI_file [`interimap`(1)]: interimap.1.html [LMTP server]: https://doc.dovecot.org/latest/core/config/delivery/lmtp.html [Maildir]: https://en.wikipedia.org/wiki/Maildir [mbox]: https://en.wikipedia.org/wiki/Mbox [MTA]: https://en.wikipedia.org/wiki/Message_transfer_agent [Mutt]: http://mutt.org/ [OfflineIMAP]: https://www.offlineimap.org/ [RFC 7162]: https://tools.ietf.org/html/rfc7162 [Unix philosophy]: https://en.wikipedia.org/wiki/Unix_philosophy interimap-0.5.8/doc/index.md 0000664 0000000 0000000 00000002730 15003201720 0015702 0 ustar 00root root 0000000 0000000 % InterIMAP & PullIMAP % [Guilhem Moulin](mailto:guilhem@fripost.org) InterIMAP synchronizes emails and their metadata between a remote IMAP server and local storage. By leveraging the [*Quick Mailbox Resynchronization*][RFC 7162] IMAP extension, it can offer [*much better performance*](benchmark.html) than tools such as [OfflineIMAP]. PullIMAP retrieves messages from an IMAP mailbox and deliver them locally. It can use the the [*IDLE*][RFC 2177] IMAP extension to reduce both latency and network traffic. [RFC 2177]: https://tools.ietf.org/html/rfc2177 [RFC 7162]: https://tools.ietf.org/html/rfc7162 [OfflineIMAP]: https://www.offlineimap.org/ General documentation --------------------- * [Getting started with InterIMAP](getting-started.html) * [Multi-remote setup for InterIMAP](multi-account.html) * [InterIMAP benchmark metrics and comparison](benchmark.html) * [Presentation at DebConf19](https://debconf19.debconf.org/talks/78-interimap-the-case-for-local-imap-servers-and-fast-bidirectional-synchronization/) Manuals (HTML versions) ----------------------- * [`interimap`(1)](interimap.1.html) — Fast bidirectional synchronization for IMAP servers * [`pullimap`(1)](pullimap.1.html) — Pull mails from an IMAP mailbox and deliver them to an SMTP session Resources for developers ------------------------ * [Source-code repository](https://git.guilhem.org/interimap) * [Build instructions](build.html) * [Test environment setup](development.html) interimap-0.5.8/doc/interimap.1.md 0000664 0000000 0000000 00000062100 15003201720 0016717 0 ustar 00root root 0000000 0000000 % interimap(1) % [Guilhem Moulin](mailto:guilhem@fripost.org) % July 2015 Name ==== InterIMAP - Fast bidirectional synchronization for QRESYNC-capable IMAP servers Synopsis ======== `interimap` [*OPTION* ...] [*COMMAND*] [*MAILBOX* ...] Description =========== `interimap` performs stateful synchronization between two IMAP4rev1 servers. Such synchronization is made possible by the [`QRESYNC` IMAP extension][RFC 7162]; for convenience reasons servers must also support the [`LIST-EXTENDED`][RFC 5258], [`LIST-STATUS`][RFC 5819] (or [`NOTIFY`][RFC 5465]) and [`UIDPLUS`][RFC 4315] IMAP extensions. See also the **[supported extensions](#supported-extensions)** section below. Stateful synchronization is only possible for mailboxes supporting persistent message Unique Identifiers (UID) and persistent storage of mod-sequences (MODSEQ); any non-compliant mailbox will cause `interimap` to abort. Furthermore, because UIDs are allocated not by the client but by the server, `interimap` needs to keep track of associations between local and remote UIDs for each mailbox. The synchronization state of a mailbox consists of its `UIDNEXT` and `HIGHESTMODSEQ` values on each server; it is then assumed that each message with UID smaller than `UIDNEXT` have been replicated to the other server, and that the metadata (such as flags) of each message with MODSEQ at most `HIGHESTMODSEQ` have been synchronized. Conceptually, the synchronization algorithm is derived from [RFC 4549] with the [RFC 7162] (sec. 6) amendments, and works as follows: 1. `SELECT` (on both servers) a mailbox the current `UIDNEXT` or `HIGHESTMODSEQ` values of which differ from the values found in the database (for either server). Use the `QRESYNC` `SELECT` parameter from [RFC 7162] to list changes (vanished messages and flag updates) since `HIGHESTMODSEQ` to messages with UID smaller than `UIDNEXT`. 2. Propagate these changes onto the other server: get the corresponding UIDs from the database, then: a. issue a `UID STORE` command, followed by `UID EXPUNGE`, to remove messages that have not already been deleted on both servers; and b. issue some `UID STORE` commands to propagate flag updates (send a single command for each flag list in order the reduce the number of round trips). (Conflicts may occur if the metadata of a message has been updated on both servers with different flag lists; in that case, `interimap` issues a warning and updates the message on each server with the union of both flag lists.) Repeat this step if the server sent some updates in the meantime. Otherwise, update the `HIGHESTMODSEQ` value in the database. 3. Process new messages (if the current `UIDNEXT` value of the mailbox differs from the one found in the database) by issuing a `UID FETCH` command; process each received message on-the-fly by issuing an `APPEND` command with the message's `RFC822` body, `FLAGS` and `INTERNALDATE`. Repeat this step if the server received new messages in the meantime. Otherwise, update the `UIDNEXT` value in the database. Go back to step 2 if the server sent some metadata (such as flag) updates in the meantime. 4. Go back to step 1 to proceed with the next unsynchronized mailbox. Commands ======== By default, `interimap` synchronizes each mailbox listed by the `LIST "" "*"` IMAP command; the *list-mailbox*, *list-select-opts* and *ignore-mailbox* options from the [configuration file](#configuration-file) can be used to shrink that list and save bandwidth. However if some extra argument are provided on the command line, `interimap` ignores these options and synchronizes the given *MAILBOX*es instead. Note that each *MAILBOX* is taken “as is”; in particular, it must be [UTF-7 encoded][RFC 2152], unquoted, and the list wildcards ‘\*’ and ‘%’ are passed verbatim to the IMAP server. If the local and remote hierarchy delimiter differ, then within the *MAILBOX* names the *local* delimiter should be used (it is transparently substituted for remote commands and responses). If the synchronization was interrupted during a previous run while some messages were being replicated (but before the `UIDNEXT` or `HIGHESTMODSEQ` values have been updated), `interimap` performs a “full synchronization” on theses messages: downloading the whole UID and flag lists on each servers allows `interimap` to detect messages that have been removed or for which their flags have changed in the meantime. Finally, after propagating the offline changes for these messages, `interimap` resumes the synchronization for the rest of the mailbox. Specifying one of the commands below makes `interimap` perform an action other than the default [`QRESYNC`][RFC 7162]-based synchronization. `--repair` [*MAILBOX* ...] : List the database anomalies and try to repair them. (Consider only the given *MAILBOX*es if non-optional arguments are provided.) This is done by performing a so-called “full synchronization”, namely: 1/ download all UIDs along with their flag list both from the local and remote servers; 2/ ensure that each entry in the database corresponds to an existing UID; and 3/ ensure that both flag lists match. Any message found on a server but not in the database is replicated on the other server (which in the worst case, might yield a message duplicate). Flag conflicts are solved by updating each message to the union of both lists. `--delete` *MAILBOX* [*MAILBOX* ...] : Delete the given *MAILBOX*es on each target (by default each server plus the database, unless `--target` specifies otherwise) where it exists. Note that per the [IMAP4rev1 standard][RFC 3501] deletion is not recursive. Thus *MAILBOX*'s children are not deleted. `--rename` *SOURCE* *DEST* : Rename the mailbox *SOURCE* to *DEST* on each target (by default each server plus the database, unless `--target` specifies otherwise) where it exists. `interimap` aborts if *DEST* already exists on either target. Note that per the [IMAP4rev1 standard][RFC 3501] renaming is recursive. Thus *SOURCE*'s children are moved to become *DEST*'s children instead. Options ======= `--config=`*FILE* : Specify an alternate [configuration file](#configuration-file). Relative paths start from *$XDG_CONFIG_HOME/interimap*, or *~/.config/interimap* if the `XDG_CONFIG_HOME` environment variable is unset. `--target={local,remote,database}` : Limit the scope of a `--delete` or `--rename` command to the given target. Can be repeated to act on multiple targets. By default all three targets are considered. `--watch`[`=`*seconds*] : Don't exit after a successful synchronization. Instead, keep synchronizing forever. Sleep for the given number of *seconds* (by default 1 minute if `--notify` is unset, and 15 minutes if `--notify` is set) between two synchronizations. Setting this options enables `SO_KEEPALIVE` on the socket for *type*s other than `tunnel`. `--notify` : Whether to use the [IMAP `NOTIFY` extension][RFC 5465] to instruct the server to automatically send updates to the client. (Both local and remote servers must support [RFC 5465] for this to work.) This greatly reduces IMAP traffic since `interimap` can rely on server notifications instead of manually polling for updates. If the connection remains idle for 15 minutes (configurable with `--watch`), then `interimap` sends a `NOOP` command to avoid being logged out for inactivity. `-q`, `--quiet` : Try to be quiet. `--debug` : Turn on debug mode. Debug messages, which includes all IMAP traffic besides literals, are written to the given *logfile*. The `LOGIN` and `AUTHENTICATE` commands are however redacted (in order to avoid disclosing authentication credentials) unless the `--debug` flag is set multiple times. `-h`, `--help` : Output a brief help and exit. `--version` : Show the version number and exit. Configuration file {#configuration-file} ================== Unless told otherwise by the `--config=FILE` command-line option, `interimap` reads its configuration from *$XDG_CONFIG_HOME/interimap/config* (or *~/.config/interimap/config* if the `XDG_CONFIG_HOME` environment variable is unset) as an [INI file]. The syntax of the configuration file is a series of `OPTION=VALUE` lines organized under some `[SECTION]`; lines starting with a ‘#’ or ‘;’ character are ignored as comments. The `[local]` and `[remote]` sections define the two IMAP servers to synchronize. Valid options are: *database* : SQLite version 3 database file to use to keep track of associations between local and remote UIDs, as well as the `UIDVALIDITY`, `UIDNEXT` and `HIGHESTMODSEQ` of each known mailbox on both servers. Relative paths start from *$XDG_DATA_HOME/interimap*, or *~/.local/share/interimap* if the `XDG_DATA_HOME` environment variable is unset. This option is only available in the default section. (Default: `HOST.db`, where *HOST* is taken from the `[remote]` or `[local]` sections, in that order.) *list-reference* : An optional “reference name” to use for the initial `LIST` command, indicating the context in which the *MAILBOX*es are interpreted. For instance, by specifying `list-reference=perso/` in the `[local]` section, *MAILBOX* names are interpreted relative to `perso/` on the local server; in other words the remote mailbox hierarchy is mapped to the `perso/` sub-hierarchy on the local server. This is useful for synchronizing multiple remote servers against different namespaces belonging to the same local IMAP server (using a different `interimap` instance for each local namespace ↔ remote synchronization). (Note that if the reference name is not a level of mailbox hierarchy and/or does not end with the hierarchy delimiter, by [RFC 3501] its interpretation by the IMAP server is implementation-dependent.) *list-mailbox* : A space separated list of mailbox patterns to use when issuing the initial `LIST` command (overridden by the *MAILBOX*es given as command-line arguments). Names containing special characters such as spaces or brackets need to be enclosed in double quotes. Within double quotes C-style backslash escape sequences can be used (‘\\t’ for an horizontal tab, ‘\\n’ for a new line, ‘\\\\’ for a backslash, etc.), as well as hexadecimal escape sequences ‘\\xHH’. Furthermore, non-ASCII names must be [UTF-7 encoded][RFC 2152]. Two wildcards are available, and passed verbatim to the IMAP server: a ‘\*’ character matches zero or more characters, while a ‘%’ character matches zero or more characters up to the hierarchy delimiter. Hard-coding the hierarchy delimiter in this setting is not advised because the server might silently change it at some point. A null character should be used instead. For instance, if *list-mailbox* is set `"foo\x00bar"` then, assuming the hierarchy delimiter is ‘/’, only the mailbox named `foo/bar` is considered for synchronization. This option is only available in the default section. (The default pattern, `*`, matches all visible mailboxes on the server.) *list-select-opts* : An optional space separated list of selectors for the initial `LIST` command. (Requires a server supporting the [`LIST-EXTENDED` IMAP extension][RFC 5258].) Useful values are `SUBSCRIBED` (to list only subscribed mailboxes), `REMOTE` (to also list remote mailboxes on a server supporting mailbox referrals), and `RECURSIVEMATCH` (to list parent mailboxes with children matching one of the above *list-mailbox* patterns). This option is only available in the default section. *ignore-mailbox* : An optional Perl Compatible Regular Expressions ([PCRE]) covering mailboxes to exclude: any ([UTF-7 encoded][RFC 2152] and unquoted) mailbox listed in the initial `LIST` responses is ignored if it matches the given expression after trimming the reference names and substituting the hierarchy delimiter with the null character. For instance, specifying `^virtual(?:\x00|$)` excludes the mailbox named “virtual” as well as its descendants. Note that the *MAILBOX*es given as command-line arguments bypass the check and are always considered for synchronization. This option is only available in the default section. *logfile* : A file name to use to log debug and informational messages. (By default these messages are written to the error output.) This option is only available in the default section. *log-prefix* : A `printf`(3)-like format string to use as prefix for each log message. Interpreted sequences are `%n` and `%m`, expanding respectively to the component name (*local*/*remote*) and to the name of the mailbox relevant for the log entry. Conditions on a specifier `%X` can be obtained with `%?X?then?` or `%?X?then&else?`, which expands to *then* if the `%X` specifier expands to a non-empty string, and to *else* (or the empty string if there is no else condition) if it doesn't. Literal `%` characters need to be escaped as `%%`, while `&`, `?` and `\` characters need to be `\`-escaped. (Default: `%?n?%?m?%n(%m)&%n?: ?`.) *type* : One of `imap`, `imaps` or `tunnel`. `type=imap` and `type=imaps` are respectively used for IMAP and IMAP over SSL/TLS connections over an INET socket. `type=tunnel` causes `interimap` to create an unnamed pair of connected sockets for inter-process communication with a *command* instead of opening a network socket. Note that specifying `type=tunnel` in the `[remote]` section makes the default *database* to be `localhost.db`. (Default: `imaps`.) *host* : Server hostname or IP address, for `type=imap` and `type=imaps`. The value can optionally be enclosed in square brackets to force its interpretation as an IP literal (hence skip name resolution). (Default: `localhost`.) *port* : Server port. (Default: `143` for `type=imap`, `993` for `type=imaps`.) *proxy* : Optional SOCKS proxy to use for TCP connections to the IMAP server (`type=imap` and `type=imaps` only), formatted as `PROTOCOL://[USER:PASSWORD@]PROXYHOST[:PROXYPORT]`. If `PROXYPORT` is omitted, it is assumed at port 1080. Only [SOCKSv5][RFC 1928] is supported (with optional [username/password authentication][RFC 1929]), in two flavors: `socks5://` to resolve *hostname* locally, and `socks5h://` to let the proxy resolve *hostname*. *command* : Command to use for `type=tunnel`. Must speak the [IMAP4rev1 protocol][RFC 3501] on its standard output, and understand it on its standard input. The value is passed to `` `/bin/sh -c` `` if it contains shell metacharacters; otherwise it is split into words and the resulting list is passed to `execvp`(3). *STARTTLS* : Whether to use the [`STARTTLS`][RFC 2595] directive to upgrade to a secure connection. Setting this to `YES` for a server not advertising the `STARTTLS` capability causes `interimap` to immediately abort the connection. (Ignored for *type*s other than `imap`. Default: `YES`.) *auth* : Space-separated list of preferred authentication mechanisms. `interimap` uses the first mechanism in that list that is also advertised (prefixed with `AUTH=`) in the server's capability list. Supported authentication mechanisms are `PLAIN` and `LOGIN`. (Default: `PLAIN LOGIN`.) *username*, *password* : Username and password to authenticate with. Can be required for non pre-authenticated connections, depending on the chosen authentication mechanism. *compress* : Whether to use the [`IMAP COMPRESS` extension][RFC 4978] for servers advertising it. (Default: `NO` for the `[local]` section, `YES` for the `[remote]` section.) *null-stderr* : Whether to redirect *command*'s standard error to `/dev/null` for `type=tunnel`. This option is ignored when the `--debug` flag is set. (Default: `NO`.) *SSL_protocols* : Space-separated list of SSL/TLS protocol versions to explicitly enable (or disable if prefixed with an exclamation mark `!`). Potentially known protocols are `SSLv2`, `SSLv3`, `TLSv1`, `TLSv1.1`, `TLSv1.2`, and `TLSv1.3`, depending on the OpenSSL version used. Enabling a protocol is a short-hand for disabling all other protocols. *DEPRECATED*: Use *SSL_protocol_min* and/or *SSL_protocol_max* instead. *SSL_protocol_min*, *SSL_protocol_max* : Set minimum resp. maximum SSL/TLS protocol version to use for the connection. Potentially recognized values are `SSLv3`, `TLSv1`, `TLSv1.1`, `TLSv1.2`, and `TLSv1.3`, depending on the OpenSSL version used. *SSL_cipherlist*, *SSL_ciphersuites* : Sets the TLSv1.2 and below cipher list resp. TLSv1.3 cipher suites. The combination of these lists is sent to the server, which then determines which cipher to use (normally the first supported one from the list sent by the client). The default suites depend on the OpenSSL version and its configuration, see [`ciphers`(1ssl)] for more information. *SSL_fingerprint* : Space-separated list of acceptable fingerprints for the server certificate's Subject Public Key Info, in the form `[ALGO$]DIGEST_HEX` where `ALGO` is the digest algorithm (by default `sha256`). Attempting to connect to a server with a non-matching certificate SPKI fingerprint causes `interimap` to abort the connection during the SSL/TLS handshake. The following command can be used to compute the SHA-256 digest of a certificate's Subject Public Key Info: $ openssl x509 -in /path/to/server/certificate.pem -pubkey \ | openssl pkey -pubin -outform DER \ | openssl dgst -sha256 Specifying multiple digest values can be useful in key rollover scenarios and/or when the server supports certificates of different types (for instance a dual-cert RSA/ECDSA setup). In that case the connection is aborted when none of the specified digests matches. *SSL_verify* : Whether to 1/ verify the server certificate chain; and 2/ match its Subject Alternative Name (SAN) or Subject CommonName (CN) against the value of the *host* option. (Default: `YES`.) Note that using *SSL_fingerprint* to specify the fingerprint of the server certificate provides an independent server authentication measure as it pins directly its key material and ignore its chain of trust. *SSL_CAfile* : File containing trusted certificates to use during server certificate verification when `SSL_verify=YES`. Trusted CA certificates are loaded from the default system locations unless one (or both) of *SSL_CAfile* or *SSL_CApath* is set. *SSL_CApath* : Directory to use for server certificate verification when `SSL_verify=YES`. This directory must be in “hash format”, see [`verify`(1ssl)] for more information. Trusted CA certificates are loaded from the default system locations unless one (or both) of *SSL_CAfile* or *SSL_CApath* is set. *SSL_hostname* : Name to use for the TLS SNI (Server Name Indication) extension. The default value is taken from the *host* option when it is a hostname, and to the empty string when it is an IP literal. Setting *SSL_hostname* to the empty string explicitly disables SNI. Supported extensions {#supported-extensions} ==================== `interimap` takes advantage of servers supporting the following extensions to the [IMAP4rev1 protocol][RFC 3501] (those marked as “recommended” give the most significant performance gain): * `LITERAL+` ([RFC 2088], recommended); * `MULTIAPPEND` ([RFC 3502], recommended); * `COMPRESS=DEFLATE` ([RFC 4978], recommended); * `NOTIFY` ([RFC 5465]); * `SASL-IR` ([RFC 4959]); and * `UNSELECT` ([RFC 3691]). Known bugs and limitations ========================== * Using `interimap` on two identical servers with a non-existent or empty *database* will duplicate each message due to the absence of local ↔ remote UID association. (Should they arise, an external tool such as [`doveadm-deduplicate`(1)] can be used to weed them out.) Hence one needs to manually empty the mail store on one end when migrating to `interimap` from another synchronization solution. * `interimap` is single threaded and doesn't use IMAP command pipelining. Synchronization could be boosted up by sending independent commands (such as the initial `LIST` and `STATUS` commands) to both servers in parallel, and for a given server, by sending independent commands (such as flag updates) in a pipeline. * Because the [IMAP protocol][RFC 3501] doesn't have a specific response code for when a message is moved to another mailbox (either using the `MOVE` command from [RFC 6851], or via `COPY` + `STORE` + `EXPUNGE`), moving a message causes `interimap` to believe that it was deleted while another one (which is replicated again) was added to the other mailbox in the meantime. * Because the [IMAP protocol][RFC 3501] doesn't provide a way for clients to determine whether a disappeared mailbox was deleted or renamed, `interimap` aborts when a known mailbox disappeared from one server but not the other. The `--delete` (resp. `--rename`) command should be used instead to delete (resp. rename) the mailbox on both servers as well as within `interimap`'s internal database. * `PLAIN` and `LOGIN` are the only authentication mechanisms currently supported. * `interimap` will probably not work with non [RFC][RFC 3501]-compliant servers. In particular, no work-around is currently implemented beside the tunables in the [configuration file](#configuration-file). Moreover, few IMAP servers have been tested so far. Standards ========= * M. Leech, M. Ganis, Y. Lee, R. Kuris, D. Koblas and L. Jones, _SOCKS Protocol Version 5_, [RFC 1928], March 1996. * M. Leech, _Username/Password Authentication for SOCKS V5_, [RFC 1929], March 1996. * J. Myers, _IMAP4 non-synchronizing literals_, [RFC 2088], January 1997. * D. Goldsmith and M. Davis, _A Mail-Safe Transformation Format of Unicode_, [RFC 2152], May 1997. * C. Newman, _Using TLS with IMAP, POP3 and ACAP_, [RFC 2595], June 1999. * M. Crispin, _Internet Message Access Protocol - Version 4rev1_, [RFC 3501], March 2003. * M. Crispin, _Internet Message Access Protocol (IMAP) - `MULTIAPPEND` Extension_, [RFC 3502], March 2003. * A. Melnikov, _Internet Message Access Protocol (IMAP) `UNSELECT` command_, [RFC 3691], February 2004. * M. Crispin, _Internet Message Access Protocol (IMAP) - `UIDPLUS` extension_, [RFC 4315], December 2005. * A. Melnikov, _Synchronization Operations for Disconnected IMAP4 Clients_, [RFC 4549], June 2006. * A. Gulbrandsen, _The IMAP `COMPRESS` Extension_, [RFC 4978], August 2007. * R. Siemborski and A. Gulbrandsen, _IMAP Extension for Simple Authentication and Security Layer (SASL) Initial Client Response_, [RFC 4959], September 2007. * A. Gulbrandsen and A. Melnikov, _The IMAP `ENABLE` Extension_, [RFC 5161], March 2008. * B. Leiba and A. Melnikov, _Internet Message Access Protocol version 4 - `LIST` Command Extensions_, [RFC 5258], June 2008. * A. Gulbrandsen, C. King and A. Melnikov, _The IMAP `NOTIFY` Extension_, [RFC 5465], February 2009. * A. Melnikov and T. Sirainen, _IMAP4 Extension for Returning `STATUS` Information in Extended LIST_, [RFC 5819], March 2010. * A. Gulbrandsen and N. Freed, _Internet Message Access Protocol (IMAP) - `MOVE` Extension_, [RFC 6851], January 2013. * A. Melnikov and D. Cridland, _IMAP Extensions: Quick Flag Changes Resynchronization (`CONDSTORE`) and Quick Mailbox Resynchronization (`QRESYNC`)_, [RFC 7162], May 2014. See also ======== A _getting started_ guide is available [there](getting-started.html). [RFC 7162]: https://tools.ietf.org/html/rfc7162 [RFC 5258]: https://tools.ietf.org/html/rfc5258 [RFC 5819]: https://tools.ietf.org/html/rfc5819 [RFC 4315]: https://tools.ietf.org/html/rfc4315 [RFC 4549]: https://tools.ietf.org/html/rfc4549 [RFC 2152]: https://tools.ietf.org/html/rfc2152 [RFC 3501]: https://tools.ietf.org/html/rfc3501 [RFC 1928]: https://tools.ietf.org/html/rfc1928 [RFC 1929]: https://tools.ietf.org/html/rfc1929 [RFC 2595]: https://tools.ietf.org/html/rfc2595 [RFC 4978]: https://tools.ietf.org/html/rfc4978 [RFC 2088]: https://tools.ietf.org/html/rfc2088 [RFC 3502]: https://tools.ietf.org/html/rfc3502 [RFC 4959]: https://tools.ietf.org/html/rfc4959 [RFC 3691]: https://tools.ietf.org/html/rfc3691 [RFC 6851]: https://tools.ietf.org/html/rfc6851 [RFC 5161]: https://tools.ietf.org/html/rfc5161 [RFC 5465]: https://tools.ietf.org/html/rfc5465 [INI file]: https://en.wikipedia.org/wiki/INI_file [PCRE]: https://en.wikipedia.org/wiki/Perl_Compatible_Regular_Expressions [`ciphers`(1ssl)]: https://www.openssl.org/docs/manmaster/man1/openssl-ciphers.html [`verify`(1ssl)]: https://www.openssl.org/docs/manmaster/man1/openssl-verify.html [`doveadm-deduplicate`(1)]: https://doc.dovecot.org/latest/core/man/doveadm-deduplicate.1.html interimap-0.5.8/doc/multi-account.md 0000664 0000000 0000000 00000015454 15003201720 0017366 0 ustar 00root root 0000000 0000000 % Multi-remote setup for InterIMAP % [Guilhem Moulin](mailto:guilhem@fripost.org) This document describes the setup of InterIMAP against two remotes accounts, a *personal* account at `imap.example.net`, and a *work* account at `imap.example.com`. (The same setup can be adapted to remote accounts or more.) These remotes accounts are to be synchronized locally, each being mirrored to its own local namespace in a bidirectional fashion. We assume familiarity with the [Getting Started] tutorial. Prerequisites, such as installing dependencies, creating directories, etc., won't be repeated here. Layout ====== For this example we assume the *personal* account has the following mailbox hierarchy (with delimiter `/`): INBOX debian debian/debian-devel debian/debian-project debian/debian-security archives And the *work* account the following mailbox hierarchy (with delimiter `.`): INBOX INBOX.todo attic These remote mailbox hierarchies are to be mirrored locally as: INBOX perso/INBOX perso/debian perso/debian/debian-devel perso/debian/debian-project perso/debian/debian-security perso/archives work/INBOX work/INBOX/todo work/attic Using again (arbitrarily) `/` as local hierarchy delimiter. (Since 0.5 each server can choose its own hierarchy delimiter independently on the other ones, and even change it — for instance after changing the server software — without breaking synchronization.) Note that there are alternative layouts (one can for instance replace the prefix `perso/` with the empty string), however for this example we'll focus on the above layout. Local Dovecot configuration =========================== Although it's not required (it's possible to use the default namespace for everything), we propose to use a dedicated [IMAP namespace][RFC 2342] for each remote account to mirror locally: * It provides better isolation of the locally mirrored accounts: one can use entirely independent mail storage for instance, and even different hierarchy delimiters. * One can use `perso/INBOX` as the local `INBOX`. (Per [RFC 3501] the `INBOX` mailbox has a special nature.) Or even alias a particular namespace to the default namespace. That being said, one should use aliases with care as they might have undesired side effects, such as `perso/INBOX` missing from `LIST` responses when the mailbox is used as the local `INBOX`; or confusion between `perso/work/foo` and `work/foo` when `perso/` is aliased to the default namespace. We define 3 namespaces: a default namespace holding the local `INBOX`, as well as a dedicated namespace — with a suitable prefix — for each remote account to mirror locally. Consult the [Dovecot namespaces] documentation for more information. Note that mailboxes (and the messages they contain) residing in the default namespace won't be copied over: they will only live on the local instance. $ cat >${XDG_CONFIG_HOME:-~/.config}/dovecot/dovecot.conf <<-EOF ssl = no namespace indbox { mail_driver = maildir mail_path = ~/Mail inbox = yes separator = / } namespace perso { prefix = perso/ mail_driver = maildir mail_path = ~/Mail/perso separator = / } namespace work { prefix = work/ mail_driver = maildir mail_path = ~/Mail/work separator = / } EOF We can see the three namespaces using the `` `~/.local/bin/dovecot-imap` `` wrapper defined in the [Getting Started] tutorial. $ ~/.local/bin/dovecot-imap S: * PREAUTH [CAPABILITY IMAP4rev1 …] Logged in as myuser C: a NAMESPACE S: * NAMESPACE (("" "/")("work/" "/")("perso/" "/")) NIL NIL S: a OK Namespace completed (0.001 + 0.000 secs). C: b LIST "" "*" S: * LIST (\Noselect \HasNoChildren) "/" work S: * LIST (\Noselect \HasNoChildren) "/" perso S: * LIST (\HasNoChildren) "/" INBOX S: b OK List completed (0.001 + 0.000 secs). C: q LOGOUT S: * BYE Logging out S: q OK Logout completed (0.001 + 0.000 secs). InterIMAP configuration ======================= We use a dedicated [`interimap`(1)] instance for each remote account to synchronize, starting with the *personal* account. Again, see the [Getting Started] guide for details about the configuration file and its settings. $ install -m0600 /dev/null ${XDG_CONFIG_HOME:-~/.config}/interimap/personal $ cat >${XDG_CONFIG_HOME:-~/.config}/interimap/personal <<-EOF database = personal.db [local] type = tunnel list-reference = perso/ command = exec ~/.local/bin/dovecot-imap [remote] type = imaps host = imap.example.net username = myname password = xxxxxxxx EOF $ interimap --config=personal Creating new schema in database file …/personal.db database: Created mailbox INBOX local: Created mailbox perso/INBOX […] And similarly for the *work* account: $ install -m0600 /dev/null ${XDG_CONFIG_HOME:-~/.config}/interimap/work $ cat >${XDG_CONFIG_HOME:-~/.config}/interimap/work <<-EOF database = work.db [local] type = tunnel list-reference = work/ command = exec ~/.local/bin/dovecot-imap [remote] type = imaps host = imap.example.com username = myname2 password = xxxxxxxx EOF $ interimap --config=work Creating new schema in database file …/work.db database: Created mailbox INBOX local: Created mailbox work/INBOX […] The local mail storage should now have the desired local layout: $ ~/.local/bin/dovecot-imap S: * PREAUTH [CAPABILITY IMAP4rev1 …] Logged in as myuser C: b LIST "" "*" S: * LIST (\Noselect \HasChildren) "/" work S: * LIST (\HasNoChildren) "/" work/attic S: * LIST (\HasChildren) "/" work/INBOX S: * LIST (\HasChildren) "/" work/INBOX S: * LIST (\HasNoChildren) "/" work/INBOX/todo S: * LIST (\Noselect \HasChildren) "/" perso S: * LIST (\HasNoChildren) "/" perso/INBOX S: * LIST (\HasChildren) "/" perso/debian S: * LIST (\HasNoChildren) "/" perso/debian/debian-security S: * LIST (\HasNoChildren) "/" perso/debian/debian-project S: * LIST (\HasNoChildren) "/" perso/debian/debian-devel S: * LIST (\HasNoChildren) "/" perso/archives S: * LIST (\HasNoChildren) "/" INBOX S: a OK List completed (0.003 + 0.000 + 0.002 secs). C: q LOGOUT S: * BYE Logging out S: q OK Logout completed (0.001 + 0.000 secs). Template user unit for systemd are provided in order to run these [`interimap`(1)] instances as services: $ systemctl --user enable --now interimap@{personal,work}.service [Getting Started]: getting-started.html [RFC 2342]: https://tools.ietf.org/html/rfc2342 [RFC 3501]: https://tools.ietf.org/html/rfc3501 [Dovecot namespaces]: https://doc.dovecot.org/latest/core/config/namespaces.html [`interimap`(1)]: interimap.1.html interimap-0.5.8/doc/pullimap.1.md 0000664 0000000 0000000 00000037175 15003201720 0016570 0 ustar 00root root 0000000 0000000 % pullimap(1) % [Guilhem Moulin](mailto:guilhem@fripost.org) % March 2016 Name ==== PullIMAP - Pull mails from an IMAP mailbox and deliver them to an SMTP session Synopsis ======== `pullimap` [**\-\-config=***FILE*] [**\-\-idle**[**=***SECONDS*]] [**\-\-no-delivery**] [**\-\-quiet**] *SECTION* Description =========== `pullimap` retrieves messages from an IMAP mailbox and deliver them to an SMTP or LMTP transmission channel. It can also remove old messages after a configurable retention period. A *statefile* is used to keep track of the mailbox's `UIDVALIDITY` and `UIDNEXT` values. While `pullimap` is running, the *statefile* is also used to keep track of UIDs being delivered, which avoids duplicate deliveries in case the process is interrupted. See the **[control flow](#control-flow)** section below for details. Options ======= `--config=`*FILE* : Specify an alternate [configuration file](#configuration-file). Relative paths start from *$XDG_CONFIG_HOME/pullimap*, or *~/.config/pullimap* if the `XDG_CONFIG_HOME` environment variable is unset. `--idle`[`=`*seconds*] : Don't exit after a successful poll. Instead, keep the connection open and issue `IDLE` commands (require an IMAP server supporting [RFC 2177]) to watch for updates in the mailbox. This also enables `SO_KEEPALIVE` on the socket. Each `IDLE` command is terminated after at most *seconds* (29 minutes by default) to avoid being logged out for inactivity. `--no-delivery` : Update the *statefile*, but skip SMTP/LMTP delivery. This is mostly useful for initializing the *statefile* when migrating to `pullimap` from another similar program such as [`fetchmail`(1)] or [`getmail`(1)]. `-q`, `--quiet` : Try to be quiet. `--debug` : Turn on debug mode. Debug messages, which includes all IMAP traffic besides literals, are written to the given *logfile*. The `LOGIN` and `AUTHENTICATE` commands are however redacted (in order to avoid disclosing authentication credentials) unless the `--debug` flag is set multiple times. `-h`, `--help` : Output a brief help and exit. `--version` : Show the version number and exit. Configuration file {#configuration-file} ================== Unless told otherwise by the `--config=FILE` command-line option, `pullimap` reads its configuration from *$XDG_CONFIG_HOME/pullimap/config* (or *~/.config/pullimap/config* if the `XDG_CONFIG_HOME` environment variable is unset) as an [INI file]. The syntax of the configuration file is a series of `OPTION=VALUE` lines organized under some `[SECTION]`; lines starting with a ‘#’ or ‘;’ character are ignored as comments. Valid options are: *statefile* : State file to use to keep track of the *mailbox*'s `UIDVALIDITY` and `UIDNEXT` values. Relative paths start from *$XDG_DATA_HOME/pullimap*, or *~/.local/share/pullimap* if the `XDG_DATA_HOME` environment variable is unset. (Default: the parent section name of the option.) *mailbox* : The IMAP mailbox ([UTF-7 encoded][RFC 2152] and unquoted) to pull messages from. Support for persistent message Unique Identifiers (UID) is required. (Default: `INBOX`.) *deliver-method* : `PROTOCOL:[ADDRESS]:PORT` where to deliver messages. Both [SMTP][RFC 5321] and [LMTP][RFC 2033] servers are supported, and [SMTP pipelining][RFC 2920] is used when possible. (Default: `smtp:[127.0.0.1]:25`.) *deliver-ehlo* : Name to use in `EHLO` or `LHLO` commands. (Default: `localhost.localdomain`.) *deliver-rcpt* : Message recipient. Note that the local part needs to quoted if it contains special characters; see [RFC 5321] for details. (Default: the username associated with the effective user ID of the `pullimap` process.) *purge-after* : Retention period (in days), after which messages are removed from the IMAP server. (The value is at best 24h accurate due to the IMAP `SEARCH` criterion ignoring time and timezone.) If *purge-after* is set to `0` then messages are deleted immediately after delivery. Otherwise `pullimap` issues an IMAP `SEARCH` (or extended `SEARCH` on servers advertising the [`ESEARCH`][RFC 4731] capability) command to list old messages; if `--idle` is set then the `SEARCH` command is issued again every 12 hours. *type* : One of `imap`, `imaps` or `tunnel`. `type=imap` and `type=imaps` are respectively used for IMAP and IMAP over SSL/TLS connections over an INET socket. `type=tunnel` causes `pullimap` to create an unnamed pair of connected sockets for inter-process communication with a *command* instead of opening a network socket. (Default: `imaps`.) *host* : Server hostname or IP address, for `type=imap` and `type=imaps`. The value can optionally be enclosed in square brackets to force its interpretation as an IP literal (hence skip name resolution). (Default: `localhost`.) *port* : Server port. (Default: `143` for `type=imap`, `993` for `type=imaps`.) *proxy* : Optional SOCKS proxy to use for TCP connections to the IMAP server (`type=imap` and `type=imaps` only), formatted as `PROTOCOL://[USER:PASSWORD@]PROXYHOST[:PROXYPORT]`. If `PROXYPORT` is omitted, it is assumed at port 1080. Only [SOCKSv5][RFC 1928] is supported (with optional [username/password authentication][RFC 1929]), in two flavors: `socks5://` to resolve *hostname* locally, and `socks5h://` to let the proxy resolve *hostname*. *command* : Command to use for `type=tunnel`. Must speak the [IMAP4rev1 protocol][RFC 3501] on its standard output, and understand it on its standard input. The value is passed to `` `/bin/sh -c` `` if it contains shell metacharacters; otherwise it is split into words and the resulting list is passed to `execvp`(3). *STARTTLS* : Whether to use the [`STARTTLS`][RFC 2595] directive to upgrade to a secure connection. Setting this to `YES` for a server not advertising the `STARTTLS` capability causes `pullimap` to immediately abort the connection. (Ignored for *type*s other than `imap`. Default: `YES`.) *auth* : Space-separated list of preferred authentication mechanisms. `pullimap` uses the first mechanism in that list that is also advertised (prefixed with `AUTH=`) in the server's capability list. Supported authentication mechanisms are `PLAIN` and `LOGIN`. (Default: `PLAIN LOGIN`.) *username*, *password* : Username and password to authenticate with. Can be required for non pre-authenticated connections, depending on the chosen authentication mechanism. *compress* : Whether to use the [`IMAP COMPRESS` extension][RFC 4978] for servers advertising it. (Default: `YES`.) *null-stderr* : Whether to redirect *command*'s standard error to `/dev/null` for `type=tunnel`. (Default: `NO`.) *SSL_protocols* : Space-separated list of SSL/TLS protocol versions to explicitly enable (or disable if prefixed with an exclamation mark `!`). Potentially known protocols are `SSLv2`, `SSLv3`, `TLSv1`, `TLSv1.1`, `TLSv1.2`, and `TLSv1.3`, depending on the OpenSSL version used. Enabling a protocol is a short-hand for disabling all other protocols. *DEPRECATED*: Use *SSL_protocol_min* and/or *SSL_protocol_max* instead. *SSL_protocol_min*, *SSL_protocol_max* : Set minimum resp. maximum SSL/TLS protocol version to use for the connection. Potentially recognized values are `SSLv3`, `TLSv1`, `TLSv1.1`, `TLSv1.2`, and `TLSv1.3`, depending on the OpenSSL version used. *SSL_cipherlist*, *SSL_ciphersuites* : Sets the TLSv1.2 and below cipher list resp. TLSv1.3 cipher suites. The combination of these lists is sent to the server, which then determines which cipher to use (normally the first supported one from the list sent by the client). The default suites depend on the OpenSSL version and its configuration, see [`ciphers`(1ssl)] for more information. *SSL_fingerprint* : Space-separated list of acceptable fingerprints for the server certificate's Subject Public Key Info, in the form `[ALGO$]DIGEST_HEX` where `ALGO` is the digest algorithm (by default `sha256`). Attempting to connect to a server with a non-matching certificate SPKI fingerprint causes `pullimap` to abort the connection during the SSL/TLS handshake. The following command can be used to compute the SHA-256 digest of a certificate's Subject Public Key Info: $ openssl x509 -in /path/to/server/certificate.pem -pubkey \ | openssl pkey -pubin -outform DER \ | openssl dgst -sha256 Specifying multiple digest values can be useful in key rollover scenarios and/or when the server supports certificates of different types (for instance a dual-cert RSA/ECDSA setup). In that case the connection is aborted when none of the specified digests matches. *SSL_verify* : Whether to 1/ verify the server certificate chain; and 2/ match its Subject Alternative Name (SAN) or Subject CommonName (CN) against the value of the *host* option. (Default: `YES`.) Note that using *SSL_fingerprint* to specify the fingerprint of the server certificate provides an independent server authentication measure as it pins directly its key material and ignore its chain of trust. *SSL_CAfile* : File containing trusted certificates to use during server certificate verification when `SSL_verify=YES`. Trusted CA certificates are loaded from the default system locations unless one (or both) of *SSL_CAfile* or *SSL_CApath* is set. *SSL_CApath* : Directory to use for server certificate verification when `SSL_verify=YES`. This directory must be in “hash format”, see [`verify`(1ssl)] for more information. Trusted CA certificates are loaded from the default system locations unless one (or both) of *SSL_CAfile* or *SSL_CApath* is set. *SSL_hostname* : Name to use for the TLS SNI (Server Name Indication) extension. The default value is taken from the *host* option when it is a hostname, and to the empty string when it is an IP literal. Setting *SSL_hostname* to the empty string explicitly disables SNI. Control flow {#control-flow} ============ `pullimap` opens the *statefile* corresponding to a given configuration *SECTION* with `O_DSYNC` to ensure that written data is flushed to the underlying hardware by the time [`write`(2)] returns. Moreover an exclusive lock is placed on the file descriptor immediately after opening to prevent multiple `pullimap` processes from accessing the *statefile* concurrently. Each *statefile* consists of a series of 32-bits big-endian integers. Usually there are only two integers: the first is the *mailbox*'s `UIDVALIDITY` value, and the second is the *mailbox*'s last seen `UIDNEXT` value (`pullimap` then assumes that all messages with UID smaller than this `UIDNEXT` value have already been retrieved and delivered). The [IMAP4rev1 specification][RFC 3501] does not guaranty that untagged `FETCH` responses are sent ordered by UID in response to a `UID FETCH` command. Thus it would be unsafe for `pullimap` to update the `UIDNEXT` value in its *statefile* while the `UID FETCH` command is progress. Instead, for each untagged `FETCH` response received while the `UID FETCH` command is in progress, `pullimap` delivers the message `RFC822` body to the SMTP or LMTP server (specified with *deliver-method*) then appends the message UID to the *statefile*. When the `UID FETCH` command eventually terminates, `pullimap` updates the `UIDNEXT` value in the *statefile* and truncate the file down to 8 bytes. Keeping track of message UIDs as they are received avoids duplicate in the event of a crash or connection loss while the `UID FETCH` command is in progress. In more details, `pullimap` works as follows: 1. Issue a `UID FETCH` command to retrieve message `ENVELOPE` and `RFC822` (and `UID`) with UID bigger or equal than the `UIDNEXT` value found in the *statefile*. While the `UID FETCH` command is in progress, perform the following for each untagged `FETCH` response sent by the server: i. if no SMTP/LMTP transmission channel was opened, open one to the server specified with *deliver-method* and send an `EHLO` (or `LHO`) command with the domain specified by *deliver-ehlo* (the channel is kept open and shared for all messages retrieved while the `UID FETCH` IMAP command is in progress); i. perform a mail transaction (using [SMTP pipelining][RFC 2920] if possible) to deliver the retrieved message `RFC822` body to the SMTP or LMTP session; and i. append the message UID to the *statefile*. 2. If an SMTP/LMTP transmission channel was opened, send a `QUIT` command to terminate it gracefully. 3. Issue a `UID STORE` command to mark all retrieved messages (and stalled UIDs found in the *statefile* after the eighth byte) as `\Seen`. 4. Update the *statefile* with the new UIDNEXT value (bytes 5-8). 5. Truncate the *statefile* down to 8 bytes (so that it contains only two 32-bits integers, respectively the *mailbox*'s current `UIDVALIDITY` and `UIDNEXT` values). 6. If `--idle` was set, issue an `IDLE` command; stop idling and go back to step 1 when a new message is received (or when the `IDLE` timeout expires). Standards ========= * M. Leech, M. Ganis, Y. Lee, R. Kuris, D. Koblas and L. Jones, _SOCKS Protocol Version 5_, [RFC 1928], March 1996. * M. Leech, _Username/Password Authentication for SOCKS V5_, [RFC 1929], March 1996. * J. Myers, _Local Mail Transfer Protocol_, [RFC 2033], October 1996. * J. Myers, _IMAP4 non-synchronizing literals_, [RFC 2088], January 1997. * D. Goldsmith and M. Davis, _A Mail-Safe Transformation Format of Unicode_, [RFC 2152], May 1997. * B. Leiba, _IMAP4 `IDLE` command_, [RFC 2177], June 1997. * C. Newman, _Using TLS with IMAP, POP3 and ACAP_, [RFC 2595], June 1999. * N. Freed, _SMTP Service Extension for Command Pipelining_, [RFC 2920], September 2000. * M. Crispin, _Internet Message Access Protocol - Version 4rev1_, [RFC 3501], March 2003. * M. Crispin, _Internet Message Access Protocol (IMAP) - `UIDPLUS` extension_, [RFC 4315], December 2005. * A. Gulbrandsen, _The IMAP `COMPRESS` Extension_, [RFC 4978], August 2007. * A. Melnikov and D. Cridland, _IMAP4 Extension to SEARCH Command for Controlling What Kind of Information Is Returned_, [RFC 4731], November 2006. * R. Siemborski and A. Gulbrandsen, _IMAP Extension for Simple Authentication and Security Layer (SASL) Initial Client Response_, [RFC 4959], September 2007. * J. Klensin, _Simple Mail Transfer Protocol_, [RFC 5321], October 2008. [RFC 4315]: https://tools.ietf.org/html/rfc4315 [RFC 2177]: https://tools.ietf.org/html/rfc2177 [RFC 2595]: https://tools.ietf.org/html/rfc2595 [RFC 4959]: https://tools.ietf.org/html/rfc4959 [RFC 2152]: https://tools.ietf.org/html/rfc2152 [RFC 2088]: https://tools.ietf.org/html/rfc2088 [RFC 5321]: https://tools.ietf.org/html/rfc5321 [RFC 2033]: https://tools.ietf.org/html/rfc2033 [RFC 2920]: https://tools.ietf.org/html/rfc2920 [RFC 3501]: https://tools.ietf.org/html/rfc3501 [RFC 4978]: https://tools.ietf.org/html/rfc4978 [RFC 1928]: https://tools.ietf.org/html/rfc1928 [RFC 1929]: https://tools.ietf.org/html/rfc1929 [RFC 4731]: https://tools.ietf.org/html/rfc4731 [INI file]: https://en.wikipedia.org/wiki/INI_file [`fetchmail`(1)]: https://www.fetchmail.info/ [`getmail`(1)]: http://pyropus.ca/software/getmail/ [`write`(2)]: https://man7.org/linux/man-pages/man2/write.2.html [`ciphers`(1ssl)]: https://www.openssl.org/docs/manmaster/man1/openssl-ciphers.html [`verify`(1ssl)]: https://www.openssl.org/docs/manmaster/man1/openssl-verify.html interimap-0.5.8/doc/template.html 0000664 0000000 0000000 00000005146 15003201720 0016756 0 ustar 00root root 0000000 0000000 $for(author-meta)$ $endfor$ $if(date-meta)$ $endif$ $if(keywords)$ $endif$ $if(title-prefix)$$title-prefix$ – $endif$$pagetitle$ $if(highlighting-css)$ $endif$ $for(css)$ $endfor$ $if(math)$ $math$ $endif$ $for(header-includes)$ $header-includes$ $endfor$ $for(include-before)$ $include-before$ $endfor$ $if(title)$ $if(parent)$ Parent $endif$ $title$ $endif$ $body$ interimap-0.5.8/interimap 0000775 0000000 0000000 00000200712 15003201720 0015422 0 ustar 00root root 0000000 0000000 #!/usr/bin/perl -T #---------------------------------------------------------------------- # Fast bidirectional synchronization for QRESYNC-capable IMAP servers # Copyright © 2015-2022 Guilhem Moulin # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . #---------------------------------------------------------------------- use v5.14.2; use strict; use warnings; our $VERSION = '0.5.8'; my $NAME = 'interimap'; my $DATABASE_VERSION = 1; use Getopt::Long qw/:config posix_default no_ignore_case gnu_compat bundling auto_version/; use DBI ':sql_types'; use DBD::SQLite::Constants ':file_open'; use Fcntl qw/O_WRONLY O_CREAT O_EXCL F_GETFD F_SETFD FD_CLOEXEC/; use List::Util 'first'; use lib "./lib"; use Net::IMAP::InterIMAP 0.5.8 qw/xdg_basedir read_config compact_set/; # Clean up PATH $ENV{PATH} = join ':', qw{/usr/bin /bin}; delete @ENV{qw/IFS CDPATH ENV BASH_ENV/}; my %CONFIG; sub usage(;$) { my $rv = shift // 0; if ($rv) { print STDERR "Usage: $NAME [OPTIONS] [COMMAND] [MAILBOX [..]]\n" ."Try '$NAME --help' or consult the manpage for more information.\n"; } else { print STDERR "Usage: $NAME [OPTIONS] [MAILBOX [..]]\n" ." or: $NAME [OPTIONS] --repair [MAILBOX [..]]\n" ." or: $NAME [OPTIONS] --delete MAILBOX [..]\n" ." or: $NAME [OPTIONS] --rename SOURCE DEST\n" ."Consult the manpage for more information.\n"; } exit $rv; } my @COMMANDS = qw/repair delete rename/; usage(1) unless GetOptions(\%CONFIG, qw/config=s quiet|q target=s@ debug+ help|h watch:i notify/, @COMMANDS); usage(0) if $CONFIG{help}; my $COMMAND = do { my @command = grep {exists $CONFIG{$_}} @COMMANDS; usage(1) if $#command>0; $command[0] }; usage(1) if defined $COMMAND and (($COMMAND eq 'delete' and !@ARGV) or ($COMMAND eq 'rename' and $#ARGV != 1)); usage(1) if defined $COMMAND and (defined $CONFIG{watch} or defined $CONFIG{notify}); usage(1) if $CONFIG{target} and !(defined $COMMAND and ($COMMAND eq 'delete' or $COMMAND eq 'rename')); $CONFIG{watch} = $CONFIG{notify} ? 900 : 60 if (defined $CONFIG{watch} or $CONFIG{notify}) and !$CONFIG{watch}; @ARGV = map {uc $_ eq 'INBOX' ? 'INBOX' : $_ } @ARGV; # INBOX is case-insensitive die "Invalid mailbox name $_" foreach grep !/\A[\x01-\x7F]+\z/, @ARGV; my $CONF = do { my $conffile = delete($CONFIG{config}) // "config"; $conffile = xdg_basedir( XDG_CONFIG_HOME => ".config", $NAME, $conffile ); read_config( $conffile , [qw/_ local remote/] , database => qr/\A(\P{Control}+)\z/ , logfile => qr/\A(\/\P{Control}+)\z/ , 'log-prefix' => qr/\A(\P{Control}*)\z/ , 'list-reference' => qr/\A([\x01-\x09\x0B\x0C\x0E-\x7F]*)\z/ , 'list-mailbox' => qr/\A([\x01-\x09\x0B\x0C\x0E-\x7F]+)\z/ , 'list-select-opts' => qr/\A([\x20\x21\x23\x24\x26\x27\x2B-\x5B\x5E-\x7A\x7C-\x7E]*)\z/ , 'ignore-mailbox' => qr/\A([\x01-\x09\x0B\x0C\x0E-\x7F]+)\z/ ); }; my ($DBFILE, %LOGGER_CONF, %LIST); { $CONF->{_} //= {}; $DBFILE = $CONF->{_}->{database}; $DBFILE //= $CONF->{remote}->{host}.'.db' if defined $CONF->{remote}; $DBFILE //= $CONF->{local}->{host}. '.db' if defined $CONF->{local}; die "Missing option database" unless defined $DBFILE; $DBFILE = xdg_basedir( XDG_DATA_HOME => ".local/share", $NAME, $DBFILE ); $LOGGER_CONF{'logger-prefix'} = $CONF->{_}->{'log-prefix'} // "%?n?%?m?%n(%m)&%n?: ?"; if (defined (my $l = $CONF->{_}->{logfile})) { require 'POSIX.pm'; require 'Time/HiRes.pm'; open my $fd, '>>', $l or die "Can't open $l: $!\n"; $fd->autoflush(1); my $flags = fcntl($fd, F_GETFD, 0) or die "fcntl F_GETFD: $!"; fcntl($fd, F_SETFD, $flags | FD_CLOEXEC) or die "fcntl F_SETFD: $!"; $LOGGER_CONF{'logger-fd'} = $fd; } $LIST{mailbox} = [@ARGV]; if (!defined $COMMAND or $COMMAND eq 'repair') { if (!@ARGV and defined (my $v = $CONF->{_}->{'list-mailbox'})) { my @mailbox; do { if ($v =~ s/\A[\x21\x23-\x27\x2A-\x5B\x5D-\x7A\x7C-\x7E]+//p) { push @mailbox, ${^MATCH}; } elsif ($v =~ s/\A\"((?: [\x20\x21\x23-\x5B\x5D-\x7E] | # the above plus \x20\x28\x29\x7B (?:\\(?:[\x22\x5C0abtnvfr] | x\p{AHex}{2})) # quoted char or hex-encoded pair )+)\"//x) { push @mailbox, $1 =~ s/\\(?:[\x22\x5C0abtnvfr]|x\p{AHex}{2})/"\"${^MATCH}\""/greep; } } while ($v =~ s/\A\s+//); die "Invalid value for list-mailbox: ".$CONF->{_}->{'list-mailbox'}."\n" if $v ne ""; $LIST{mailbox} = \@mailbox; } $LIST{'select-opts'} = uc($CONF->{_}->{'list-select-opts'}) if defined $CONF->{_}->{'list-select-opts'} and $CONF->{_}->{'list-select-opts'} ne ""; $LIST{params} = [ "SUBSCRIBED" ]; # RFC 5258 - LIST Command Extensions push @{$LIST{params}}, "STATUS (UIDVALIDITY UIDNEXT HIGHESTMODSEQ)" # RFC 5819 - Returning STATUS Information in Extended LIST unless $CONFIG{notify}; } if (defined (my $t = $CONFIG{target})) { @$t = map { split(",", $_) } @$t; die "Invalid target $_\n" foreach grep !/^(?:local|remote|database)$/, @$t; $CONFIG{target} = {}; $CONFIG{target}->{$_} = 1 foreach @$t; } else { $CONFIG{target} = {}; $CONFIG{target}->{$_} = 1 foreach qw/local remote database/; } $CONF->{$_}->{'list-reference'} //= "" foreach qw/local remote/; } my $DBH; # Clean after us my ($IMAP, $lIMAP, $rIMAP); sub cleanup() { undef $_ foreach grep defined, ($IMAP, $lIMAP, $rIMAP); logger(undef, "Cleaning up...") if $CONFIG{debug}; $LOGGER_CONF{'logger-fd'}->close() if defined $LOGGER_CONF{'logger-fd'}; $DBH->disconnect() if defined $DBH; } $SIG{INT} = sub { msg(undef, $!); cleanup(); exit 1; }; $SIG{TERM} = sub { cleanup(); exit 0; }; ############################################################################# # Open (and maybe create) the database { # don't auto-create in long-lived mode unless ($CONFIG{watch} or -e $DBFILE) { sysopen(my $fh, $DBFILE, O_WRONLY | O_CREAT | O_EXCL, 0600) or die "Can't create $DBFILE: $!"; close $fh or warn "close: $!"; } my $dbi_data_source = "dbi:SQLite:dbname=".$DBFILE; my %dbi_attrs = ( AutoCommit => 0, RaiseError => 1, sqlite_use_immediate_transaction => 1, sqlite_open_flags => SQLITE_OPEN_READWRITE ); $DBH = DBI::->connect($dbi_data_source, undef, undef, \%dbi_attrs); $DBH->sqlite_busy_timeout(250); # Try to lock the database before any network traffic so we can fail # early if the database is already locked. $DBH->do("PRAGMA locking_mode = EXCLUSIVE"); $DBH->{AutoCommit} = 1; # turned back off later $DBH->do("PRAGMA foreign_keys = OFF"); # toggled later (no-op if not in autocommit mode) } sub msg($@) { my %h = ( %LOGGER_CONF, name => shift ); return Net::IMAP::InterIMAP::log(\%h, @_); } sub msg2($$@) { my $name = shift; my $mailbox = mbx_name($name => shift); my %h = ( %LOGGER_CONF, name => $name, mailbox => $mailbox ); return Net::IMAP::InterIMAP::log(\%h, @_); } sub logger($@) { my %h = ( %LOGGER_CONF, name => shift ); return Net::IMAP::InterIMAP::logger(\%h, @_); } sub fail($@) { my $name = shift; msg($name, "ERROR: ", @_); exit 1; } logger(undef, ">>> $NAME $VERSION") if $CONFIG{debug}; ############################################################################# # Connect to the local and remote IMAP servers foreach my $name (qw/local remote/) { my %config = %{$CONF->{$name}}; $config{$_} = $CONFIG{$_} foreach grep {defined $CONFIG{$_}} qw/quiet debug/; $config{enable} = 'QRESYNC'; $config{name} = $name; $config{$_} = $LOGGER_CONF{$_} foreach keys %LOGGER_CONF; $config{'compress'} //= ($name eq 'local' ? 0 : 1); $config{keepalive} = 1 if $CONFIG{watch} and $config{type} ne 'tunnel'; my $client = Net::IMAP::InterIMAP::->new(%config); $IMAP->{$name} = { client => $client }; die "Non $_-capable IMAP server.\n" foreach $client->incapable(qw/LIST-EXTENDED UIDPLUS/); die "Non LIST-STATUS-capable IMAP server.\n" if !$CONFIG{notify} and $client->incapable('LIST-STATUS'); } # Pretty-print hierarchy delimiter: DQUOTE QUOTED-CHAR DQUOTE / nil sub print_delimiter($) { my $d = shift // return "NIL"; $d = "\\".$d if $d eq "\\" or $d eq "\""; return "\"".$d."\""; } # Return the delimiter of the default namespace or reference, and cache the # result. Use the cached value if present, otherwise issue a new LIST # command with the empty mailbox. sub get_delimiter($$$) { my ($name, $imap, $ref) = @_; # Use the cached value if present return $imap->{delimiter} if exists $imap->{delimiter}; my (undef, $d) = $imap->{client}->list($ref." \"\""); # $ref is already quoted my @d = values %$d if defined $d; # While multiple LIST responses may happen in theory, we've issued a # single LIST command, so it's fair to expect a single reponse with # a hierarchy delimiter of the root node or reference (we can't # match the root against the reference as it might not be rooted). fail($name, "Missing or unexpected (unsolicited) LIST response.") unless $#d == 0; return $imap->{delimiter} = $d[0]; # cache value and return it } # List mailboxes; don't return anything but update $IMAP->{$name}->{mailboxes} sub list_mailboxes($) { my $name = shift; my $imap = $IMAP->{$name}; my $ref = Net::IMAP::InterIMAP::quote($CONF->{$name}->{'list-reference'}); my $list = ""; $list .= "(" .$LIST{'select-opts'}. ") " if defined $LIST{'select-opts'}; $list .= $ref." "; my @mailboxes = @{$LIST{mailbox}}; my $cached_delimiter = exists $imap->{delimiter} ? 1 : 0; if (grep { index($_,"\x00") >= 0 } @mailboxes) { # some mailbox names contain null characters: substitute them with the hierarchy delimiter my $d = get_delimiter($name, $imap, $ref) // fail($name, "Mailbox name contains null characters but the namespace is flat!"); s/\x00/$d/g foreach @mailboxes; } $list .= $#mailboxes < 0 ? "*" : $#mailboxes == 0 ? Net::IMAP::InterIMAP::quote($mailboxes[0]) : "(".join(" ", map {Net::IMAP::InterIMAP::quote($_)} @mailboxes).")"; my ($mbx, $delims) = $imap->{client}->list($list, @{$LIST{params} // []}); $imap->{mailboxes} = $mbx; # INBOX exists in a namespace of its own, so it may have a different separator. # All other mailboxes MUST have the same separator though, per 3501 sec. 7.2.2 # and https://imapwiki.org/ClientImplementation/MailboxList#Hierarchy_separators # (We assume all list-mailbox arguments given live in the same namespace. Otherwise # the user needs to start multiple interimap instances.) delete $delims->{INBOX}; unless (exists $imap->{delimiter}) { # if the delimiter is still unknown (meaning no names in @{$LIST{mailbox}} # contains null characters) we now cache it if (%$delims) { # got a non-INBOX LIST reply, use the first one as authoritative value my ($m) = sort keys %$delims; $imap->{delimiter} = delete $delims->{$m}; } else { # didn't get a non-INBOX LIST reply so we need to explicitly query # the hierarchy delimiter get_delimiter($name, $imap, $ref); } } logger($name, "Using ", print_delimiter($imap->{delimiter}), " as hierarchy delimiter") if !$cached_delimiter and $CONFIG{debug}; # Ensure all LISTed delimiters (incl. INBOX's children, although they're # in a different namespace -- we treat INBOX itself separately, but not # its children) match the one at the top level (root or reference). my $d = $imap->{delimiter}; foreach my $m (keys %$delims) { fail($name, "Mailbox $m has hierarchy delimiter ", print_delimiter($delims->{$m}), ", while ", print_delimiter($d), " was expected.") if (defined $d xor defined $delims->{$m}) or (defined $d and defined $delims->{$m} and $d ne $delims->{$m}); } } list_mailboxes("local"); if (defined (my $d = $IMAP->{local}->{delimiter})) { # substitute the local delimiter with null characters in the mailbox list s/\Q$d\E/\x00/g foreach @{$LIST{mailbox}}; } list_mailboxes("remote"); # Ensure local and remote namespaces are either both flat, or both hierarchical. # (We can't mirror a hierarchical namespace to a flat one.) fail(undef, "Local and remote namespaces are neither both flat nor both hierarchical ", "(local ", print_delimiter($IMAP->{local}->{delimiter}), ", ", "remote ", print_delimiter($IMAP->{remote}->{delimiter}), ").") if defined $IMAP->{local}->{delimiter} xor defined $IMAP->{remote}->{delimiter}; ############################################################################## # Create or update database schema (delayed until after the IMAP # connections and mailbox LISTing as we need to know the hierarchy # delimiter for the schema migration). { # Invariants: # * UIDVALIDITY never changes. # * All changes for UID < {local,remote}.UIDNEXT and MODSEQ < # {local,remote}.HIGHESTMODSEQ have been propagated. # * No local (resp. remote) new message will ever have a UID <= local.UIDNEXT # (resp. <= remote.UIDNEXT). # * Any idx in `local` must be present in `remote` and vice-versa. # * Any idx in `mapping` must be present in `local` and `remote`. my @schema = ( mailboxes => [ q{idx INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT}, # to avoid caching hierachy delimiter of mailbox names forever we replace it # with '\0' in that table; the substitution is safe since null characters are # not allowed within mailbox names q{mailbox BLOB COLLATE BINARY NOT NULL CHECK (mailbox != '') UNIQUE}, q{subscribed BOOLEAN NOT NULL} ], local => [ q{idx INTEGER NOT NULL PRIMARY KEY REFERENCES mailboxes(idx)}, # no UNIQUE constraint on UIDVALIDITY as two mailboxes may share the same value q{UIDVALIDITY UNSIGNED INT NOT NULL CHECK (UIDVALIDITY > 0)}, q{UIDNEXT UNSIGNED INT NOT NULL}, # 0 initially q{HIGHESTMODSEQ UNSIGNED BIGINT NOT NULL} # 0 initially (/!\ converted to 8-byte signed integer) # one-to-one correspondence between local.idx and remote.idx ], remote => [ q{idx INTEGER NOT NULL PRIMARY KEY REFERENCES mailboxes(idx)}, # no UNIQUE constraint on UIDVALIDITY as two mailboxes may share the same value q{UIDVALIDITY UNSIGNED INT NOT NULL CHECK (UIDVALIDITY > 0)}, q{UIDNEXT UNSIGNED INT NOT NULL}, # 0 initially q{HIGHESTMODSEQ UNSIGNED BIGINT NOT NULL} # 0 initially (/!\ converted to 8-byte signed integer) # one-to-one correspondence between local.idx and remote.idx ], mapping => [ q{idx INTEGER NOT NULL REFERENCES mailboxes(idx)}, q{lUID UNSIGNED INT NOT NULL CHECK (lUID > 0)}, q{rUID UNSIGNED INT NOT NULL CHECK (rUID > 0)}, q{PRIMARY KEY (idx,lUID)}, q{UNIQUE (idx,rUID)} # also, lUID < local.UIDNEXT and rUID < remote.UIDNEXT (except for interrupted syncs) # mapping.idx must be found among local.idx (and remote.idx) ], ); # Use the user_version PRAGMA (0 if unset) to keep track of schema # version https://sqlite.org/pragma.html#pragma_user_version my ($schema_version) = $DBH->selectrow_array("PRAGMA user_version"); if ($schema_version < $DATABASE_VERSION) { # schema creation or upgrade required $DBH->begin_work(); if ($schema_version == 0) { my $sth = $DBH->table_info(undef, undef, undef, "TABLE"); unless (defined $sth->fetch()) { # there are no tables, create everything msg(undef, "Creating new schema in database file $DBFILE"); for (my $i = 0; $i <= $#schema; $i+=2) { $DBH->do("CREATE TABLE $schema[$i] (".join(", ", @{$schema[$i+1]}).")"); } goto SCHEMA_DONE; # skip the below migrations } } msg(undef, "Upgrading database version from $schema_version"); # 12-step procedure from https://www.sqlite.org/lang_altertable.html if ($schema_version < 1) { fail(undef, "Local and remote hierachy delimiters differ ", "(local ", print_delimiter($IMAP->{local}->{delimiter}), ", ", "remote ", print_delimiter($IMAP->{remote}->{delimiter}), "), ", "refusing to update table \`mailboxes\`.") if defined $IMAP->{local}->{delimiter} and defined $IMAP->{remote}->{delimiter} # we failed earlier if only one of them was NIL and $IMAP->{local}->{delimiter} ne $IMAP->{remote}->{delimiter}; $DBH->do("CREATE TABLE _tmp${DATABASE_VERSION}_mailboxes (". join(", ", q{idx INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT}, q{mailbox BLOB COLLATE BINARY NOT NULL CHECK (mailbox != '') UNIQUE}, q{subscribed BOOLEAN NOT NULL} ).")"); if (defined (my $d = $IMAP->{local}->{delimiter})) { # local and remote delimiters match, replace them with null characters my $sth = $DBH->prepare("INSERT INTO _tmp${DATABASE_VERSION}_mailboxes SELECT idx, CAST(REPLACE(mailbox, ?, x'00') AS BLOB), subscribed FROM mailboxes"); $sth->bind_param(1, $IMAP->{local}->{delimiter}, SQL_VARCHAR); $sth->execute(); } else { # treat all mailboxes as flat (\NoInferiors names) $DBH->do("INSERT INTO _tmp${DATABASE_VERSION}_mailboxes SELECT * FROM mailboxes"); } $DBH->do("DROP TABLE mailboxes"); $DBH->do("ALTER TABLE _tmp${DATABASE_VERSION}_mailboxes RENAME TO mailboxes"); } fail("database", "Broken referential integrity! Refusing to commit changes.") if defined $DBH->selectrow_arrayref("PRAGMA foreign_key_check"); SCHEMA_DONE: $DBH->do("PRAGMA user_version = $DATABASE_VERSION"); $DBH->commit(); } $DBH->do("PRAGMA foreign_keys = ON"); # no-op if not in autocommit mode $DBH->{AutoCommit} = 0; # always explicitly commit changes } ############################################################################## # # Add a new mailbox to the database. # WARN: does not commit changes! sub db_create_mailbox($$) { my ($mailbox, $subscribed) = @_;; state $sth = $DBH->prepare(q{INSERT INTO mailboxes (mailbox,subscribed) VALUES (?,?)}); $sth->bind_param(1, $mailbox, SQL_BLOB); $sth->bind_param(2, $subscribed, SQL_BOOLEAN); my $r = $sth->execute(); msg("database", "Created mailbox ", mbx_pretty($mailbox)); return $r; } # Get the index associated with a mailbox. sub db_get_mailbox_idx($) { my $mailbox = shift; state $sth = $DBH->prepare(q{SELECT idx,subscribed FROM mailboxes WHERE mailbox = ?}); $sth->bind_param(1, $mailbox, SQL_BLOB); $sth->execute(); my ($idx, $subscribed) = $sth->fetchrow_array(); die if defined $sth->fetch(); # safety check (we have a UNIQUE contstraint though) return wantarray ? ($idx, $subscribed) : $idx; } # Transform mailbox name from internal representation (with \0 as hierarchy delimiters # and without reference prefix) to a name understandable by the local/remote IMAP server. sub mbx_name($$) { my ($name, $mailbox) = @_; my $x = $name // "local"; # don't add reference if $name is undefined if (defined (my $d = $IMAP->{$x}->{delimiter})) { $mailbox =~ s/\x00/$d/g; } elsif (!exists $IMAP->{$x}->{delimiter} or index($mailbox,"\x00") >= 0) { die; # safety check } return defined $name ? ($CONF->{$name}->{"list-reference"} . $mailbox) : $mailbox; } sub mbx_pretty($) { return mbx_name(undef, $_[0]); } # Transform mailbox name from local/remote IMAP server to the internal representation # (with \0 as hierarchy delimiters and without reference prefix). Return undef if # the name doesn't start with the right reference. sub mbx_unname($$) { my ($name, $mailbox) = @_; return unless defined $mailbox; my $ref = $CONF->{$name}->{"list-reference"}; return unless rindex($mailbox, $ref, 0) == 0; # not for us $mailbox = substr($mailbox, length $ref); if (defined (my $d = $IMAP->{$name}->{delimiter})) { $mailbox =~ s/\Q$d\E/\x00/g; } elsif (!exists $IMAP->{$name}->{delimiter}) { die; # safety check } return $mailbox; } # Format a message with format controls for local/remote/database mailbox names. sub fmt($@) { my $msg = shift; $msg =~ s/%([lrds])/ $1 eq "l" ? mbx_name("local", shift) : $1 eq "r" ? mbx_name("remote", shift) : $1 eq "d" ? mbx_name(undef, shift) : $1 eq "s" ? shift : die /ge; return $msg; } # Return true if $mailbox exists on $name sub mbx_exists($$) { my ($name, $mailbox) = @_; my $attrs = $IMAP->{$name}->{mailboxes}->{$mailbox}; my ($ne, $ns) = (lc '\NonExistent', lc '\NoSelect'); return (defined $attrs and !grep {my $a = lc; $a eq $ne or $a eq $ns} @$attrs) ? 1 : 0; } # Return true if $mailbox is subscribed to on $name sub mbx_subscribed($$) { my ($name, $mailbox) = @_; my $attrs = $IMAP->{$name}->{mailboxes}->{$mailbox}; return (defined $attrs and grep {lc $_ eq lc '\Subscribed'} @$attrs) ? 1 : 0; } ############################################################################## # Process --delete command # if (defined $COMMAND and $COMMAND eq 'delete') { if (defined (my $d = $IMAP->{local}->{delimiter})) { s/\Q$d\E/\x00/g foreach @ARGV; } my @statements = map { $DBH->prepare("DELETE FROM $_ WHERE idx = ?") } # non-referenced tables first to avoid violating # FOREIGN KEY constraints qw/mapping local remote mailboxes/ if @ARGV and $CONFIG{target}->{database}; foreach my $mailbox (@ARGV) { fail(undef, "INBOX can't be deleted") if uc($mailbox) eq "INBOX"; # RFC 3501 sec. 6.3.4 my $idx = db_get_mailbox_idx($mailbox); # delete $mailbox on servers where $mailbox exists. note that # there is a race condition where the mailbox could have # appeared meanwhile foreach my $name (qw/local remote/) { my $mbx = mbx_name($name, $mailbox); $IMAP->{$name}->{client}->delete($mbx) if $CONFIG{target}->{$name} and mbx_exists($name, $mbx); } if (defined $idx and $CONFIG{target}->{database}) { foreach my $sth (@statements) { $sth->bind_param(1, $idx, SQL_INTEGER); $sth->execute(); } $DBH->commit(); msg("database", "Removed mailbox ", mbx_pretty($mailbox)); } } exit 0; } ############################################################################## # Process --rename command # elsif (defined $COMMAND and $COMMAND eq 'rename') { my ($from, $to) = @ARGV; if (defined (my $d = $IMAP->{local}->{delimiter})) { s/\Q$d\E/\x00/g foreach ($from, $to); } # get index of the original name my $idx = db_get_mailbox_idx($from); # ensure the target name doesn't already exist on the servers. there # is a race condition where the mailbox would be created before we # issue the RENAME command, then the server would reply with a # tagged NO response foreach my $name (qw/local remote/) { my $mbx = mbx_name($name, $to); next unless $CONFIG{target}->{$name} and mbx_exists($name, $mbx); fail($name, "Mailbox $mbx exists. Run `$NAME --target=$name --delete ", mbx_pretty($to), "` to delete."); } # ensure the target name doesn't already exist in the database my $to_pretty = mbx_pretty($to); fail("database", "Mailbox $to_pretty exists. Run `$NAME --target=database ", "--delete $to_pretty` to delete.") if $CONFIG{target}->{database} and defined db_get_mailbox_idx($to); # rename $from to $to on servers where $from if LISTed. again there is a # race condition, but if $to has been created meanwhile the server will # reply with a tagged NO response foreach my $name (qw/local remote/) { next unless $CONFIG{target}->{$name}; my ($from, $to) = ( mbx_name($name,$from), mbx_name($name, $to) ); # don't use mbx_exists() here, as \NonExistent names can be renamed # too (for instance if they have children) $IMAP->{$name}->{client}->rename($from, $to) if defined $IMAP->{$name}->{mailboxes}->{$from}; } # rename from to $to in the database if ($CONFIG{target}->{database}) { my $r = 0; if (defined $idx) { my $sth_rename_mailbox = $DBH->prepare(q{ UPDATE mailboxes SET mailbox = ? WHERE idx = ? }); $sth_rename_mailbox->bind_param(1, $to, SQL_BLOB); $sth_rename_mailbox->bind_param(2, $idx, SQL_INTEGER); $r += $sth_rename_mailbox->execute(); } # now rename the children as well my $prefix = $from."\x00"; my $sth_rename_children = $DBH->prepare(q{ UPDATE mailboxes SET mailbox = CAST(? || SUBSTR(mailbox,?) AS BLOB) WHERE SUBSTR(mailbox,1,?) = ? }); $sth_rename_children->bind_param(1, $to, SQL_BLOB); $sth_rename_children->bind_param(2, length($prefix), SQL_INTEGER); $sth_rename_children->bind_param(3, length($prefix), SQL_INTEGER); $sth_rename_children->bind_param(4, $prefix, SQL_BLOB); $r += $sth_rename_children->execute(); $DBH->commit(); msg("database", "Renamed mailbox ", mbx_pretty($from), " to ", mbx_pretty($to)) if $r > 0; } exit 0; } ############################################################################## # Synchronize mailbox and subscription lists sub sync_mailbox_list() { my (%mailboxes, @mailboxes); state $sth_subscribe = $DBH->prepare(q{ UPDATE mailboxes SET subscribed = ? WHERE idx = ? }); state $ignore_mailbox = do { my $re = $CONF->{_}->{"ignore-mailbox"}; defined $re ? qr/$re/ : undef }; foreach my $name (qw/local remote/) { foreach my $mbx (keys %{$IMAP->{$name}->{mailboxes}}) { # exclude names not starting with the given LIST reference; for instance # if "list-mailbox" specifies a name starting with a "breakout" character $mbx = mbx_unname($name, $mbx) // next; # exclude ignored mailboxes (taken from the default config as it doesn't # make sense to ignore mailboxes from one side but not the other next if !@ARGV and defined $ignore_mailbox and $mbx =~ $ignore_mailbox; $mailboxes{$mbx} = 1; } } foreach my $mailbox (keys %mailboxes) { my ($lMailbox, $rMailbox) = map {mbx_name($_, $mailbox)} qw/local remote/; my $lExists = mbx_exists("local", $lMailbox); my $rExists = mbx_exists("remote", $rMailbox); next unless $lExists or $rExists; push @mailboxes, $mailbox; my ($idx, $subscribed) = db_get_mailbox_idx($mailbox); if ($lExists and $rExists) { # $mailbox exists on both sides my $lSubscribed = mbx_subscribed("local", $lMailbox); my $rSubscribed = mbx_subscribed("remote", $rMailbox); if (defined $idx) { if ($lSubscribed xor $rSubscribed) { # mailbox is subscribed on only one server if ($subscribed) { # unsubscribe my ($imap, $mbx) = $lSubscribed ? ($lIMAP, $lMailbox) : ($rIMAP, $rMailbox); $imap->unsubscribe($mbx); } else { # subscribe my ($imap, $mbx) = $lSubscribed ? ($rIMAP, $rMailbox) : ($lIMAP, $lMailbox); $imap->subscribe($mbx); } # toggle subscribtion in the database $subscribed = $subscribed ? 0 : 1; $sth_subscribe->bind_param(1, $subscribed, SQL_BOOLEAN); $sth_subscribe->bind_param(2, $idx, SQL_INTEGER); $sth_subscribe->execute(); $DBH->commit(); } # $mailbox is either subscribed on both servers, or unsubscribed on both elsif ($lSubscribed xor $subscribed) { # $lSubscribed == $rSubscribed but database needs updating $sth_subscribe->bind_param(1, $lSubscribed, SQL_BOOLEAN); $sth_subscribe->bind_param(2, $idx, SQL_INTEGER); $sth_subscribe->execute(); $DBH->commit(); } } else { # add new mailbox; subscribe on both servers if $mailbox is subscribed on one of them my $subscribed = ($lSubscribed or $rSubscribed) ? 1 : 0; db_create_mailbox($mailbox, $subscribed); $IMAP->{local}->{client}->subscribe($lMailbox) if $subscribed and !$lSubscribed; $IMAP->{remote}->{client}->subscribe($rMailbox) if $subscribed and !$rSubscribed; $DBH->commit(); } } elsif ($lExists or $rExists) { # $mailbox is on one server only my $str = mbx_pretty($mailbox); fail("database", "Mailbox $str exists. Run `$NAME --target=database --delete $str` to delete.") if defined $idx; my ($name1, $name2, $mbx1, $mbx2) = $lExists ? ("local", "remote", $lMailbox, $rMailbox) : ("remote", "local", $rMailbox, $lMailbox); my $subscribed = mbx_subscribed($name1, $mbx1); db_create_mailbox($mailbox, $subscribed); $IMAP->{$name2}->{client}->create($mbx2, 1); $IMAP->{$name2}->{client}->subscribe($mbx2) if $subscribed; $DBH->commit(); } } return @mailboxes; } ($lIMAP, $rIMAP) = map {$IMAP->{$_}->{client}} qw/local remote/; my @MAILBOXES = sync_mailbox_list(); my $ATTRS = join ' ', qw/MODSEQ FLAGS INTERNALDATE BODY.PEEK[]/; ############################################################################# # Synchronize messages # Download some missing UIDs from $source; returns the new allocated UIDs sub download_missing($$$@) { my $idx = shift; my $mailbox = shift; my $source = shift; my @set = @_; my @uids; my $target = $source eq 'local' ? 'remote' : 'local'; my ($buff, $bufflen) = ([], 0); undef $buff if ($target eq 'local' ? $lIMAP : $rIMAP)->incapable('MULTIAPPEND'); ($source eq 'local' ? $lIMAP : $rIMAP)->fetch(compact_set(@set), "($ATTRS ENVELOPE)", sub($) { my $mail = shift; return unless exists $mail->{RFC822}; # not for us unless ($CONFIG{quiet}) { my $from = first { defined $_ and @$_ } @{$mail->{ENVELOPE}}[2,3,4]; $from = (defined $from and defined $from->[0]->[2] and defined $from->[0]->[3]) ? $from->[0]->[2].'@'.$from->[0]->[3] : ''; msg2($source => $mailbox, "UID $mail->{UID} from <$from> ($mail->{INTERNALDATE})"); } callback_new_message($idx, $mailbox, $source, $mail, \@uids, $buff, \$bufflen) }); push @uids, callback_new_message_flush($idx, $mailbox, $source, @$buff) if defined $buff and @$buff; return @uids; } # Solve a flag update conflict (by taking the union of the two flag lists). sub flag_conflict($$$$$) { my ($mailbox, $lUID, $lFlags, $rUID, $rFlags) = @_; my %flags = map {$_ => 1} (split(/ /, $lFlags), split(/ /, $rFlags)); my $flags = join ' ', sort(keys %flags); msg(undef, "WARNING: Conflicting flag update in ", mbx_pretty($mailbox), " for local UID $lUID ($lFlags) and remote UID $rUID ($rFlags).", " Setting both to the union ($flags)."); return $flags } # Delete a mapping ($idx, $lUID) from the database # WARN: Never commit before the messages have been EXPUNGEd on both sides! sub delete_mapping($$) { my ($idx, $lUID) = @_; state $sth = $DBH->prepare(q{ DELETE FROM mapping WHERE idx = ? and lUID = ? }); $sth->bind_param(1, $idx, SQL_INTEGER); $sth->bind_param(2, $lUID, SQL_INTEGER); my $r = $sth->execute(); die if $r > 1; # safety check (even if we have a UNIQUE constraint) msg('database', "WARNING: Can't delete (idx,lUID) = ($idx,$lUID)") if $r == 0; } # Create a sample (sequence numbers, UIDs) to use as Message Sequence # Match Data for the QRESYNC parameter to the SELECT command. # QRESYNC [RFC7162] doesn't force the server to remember the MODSEQs of # EXPUNGEd messages. By passing a sample of known sequence numbers/UIDs # we let the server know that the messages have been EXPUNGEd [RFC7162, # section 3.2.5.2]. # The UID set is the largest set of higest UIDs with at most 1024 UIDs, # of length (once compacted) at most 256. # The reason why we sample with the highest UIDs is that lowest UIDs are # less likely to be deleted. sub sample($$) { my ($count, $sth) = @_; return unless $count > 0; my ($n, $uids, $min, $max); $sth->execute(); # /!\ assume placeholders are bound already while (defined (my $row = $sth->fetchrow_arrayref())) { my $k = $row->[0]; if (!defined $min and !defined $max) { $n = 0; $min = $max = $k; } elsif ($k == $min - 1) { $min--; } else { $n += $max - $min + 1; $uids = ($min == $max ? $min : "$min:$max") .(defined $uids ? ','.$uids : ''); $min = $max = $k; if (length($uids) > 256) { $sth->finish(); # done with the statement last; } } } if (!defined $uids or length($uids) <= 256) { # exceed max size by at most 22 bytes ("$MIN:$MAX,") $n += $max - $min + 1; $uids = ($min == $max ? $min : "$min:$max") . (defined $uids ? ','.$uids : ''); } die unless $n <= $count; # impossible return ( ($count - $n + 1).':'.$count, $uids ); } # Issue a SELECT command with the given $mailbox. sub select_mbx($$) { my ($idx, $mailbox) = @_; # Count messages state $sth_count_messages = $DBH->prepare(q{ SELECT COUNT(*) FROM mapping WHERE idx = ? }); $sth_count_messages->bind_param(1, $idx, SQL_INTEGER); $sth_count_messages->execute(); my ($count) = $sth_count_messages->fetchrow_array(); $sth_count_messages->finish(); # List last 1024 messages UIDs state $sth_last_lUIDs = $DBH->prepare(q{ SELECT lUID FROM mapping WHERE idx = ? ORDER BY lUID DESC LIMIT 1024 }); state $sth_last_rUIDs = $DBH->prepare(q{ SELECT rUID FROM mapping WHERE idx = ? ORDER BY rUID DESC LIMIT 1024 }); $_->bind_param(1, $idx, SQL_INTEGER) foreach ($sth_last_lUIDs, $sth_last_rUIDs); $lIMAP->select(mbx_name(local => $mailbox), sample($count, $sth_last_lUIDs)); $rIMAP->select(mbx_name(remote => $mailbox), sample($count, $sth_last_rUIDs)); } # Check and repair synchronization of a mailbox between the two servers # (in a very crude way, by downloading all existing UID with their flags) sub repair($) { my $mailbox = shift; my $idx = db_get_mailbox_idx($mailbox) // return; # not in the database my $cache = db_get_cache_by_idx($idx) // return; # no cache # don't use select_mbx() as we don't need to sample here $lIMAP->select(mbx_name(local => $mailbox)); $rIMAP->select(mbx_name(remote => $mailbox)); # get all existing UID with their flags my ($lVanished, $lModified) = $lIMAP->pull_updates(1); my ($rVanished, $rModified) = $rIMAP->pull_updates(1); my (%lVanished, %rVanished); $lVanished{$_} = 1 foreach @$lVanished; $rVanished{$_} = 1 foreach @$rVanished; my (@lToRemove, %lToUpdate, @lMissing); my (@rToRemove, %rToUpdate, @rMissing); my @delete_mapping; # process each pair ($lUID,$rUID) found in the mapping table for the given index, # and compare with the result from the IMAP servers to detect anomalies state $sth_get_mappings = $DBH->prepare(q{ SELECT lUID,rUID FROM mapping WHERE idx = ? }); $sth_get_mappings->bind_param(1, $idx, SQL_INTEGER); $sth_get_mappings->execute(); while (defined (my $row = $sth_get_mappings->fetchrow_arrayref())) { my ($lUID, $rUID) = @$row; if (defined (my $l = $lModified->{$lUID}) and defined (my $r = $rModified->{$rUID})) { # both $lUID and $rUID are known; see sync_known_messages # for the sync algorithm my ($lModSeq, $lFlags) = @$l; my ($rModSeq, $rFlags) = @$r; if ($lFlags eq $rFlags) { # no conflict, whee } elsif ($lModSeq <= $cache->{lHIGHESTMODSEQ} and $rModSeq > $cache->{rHIGHESTMODSEQ}) { # set $lUID to $rFlags $lToUpdate{$rFlags} //= []; push @{$lToUpdate{$rFlags}}, $lUID; } elsif ($lModSeq > $cache->{lHIGHESTMODSEQ} and $rModSeq <= $cache->{rHIGHESTMODSEQ}) { # set $rUID to $lFlags $rToUpdate{$lFlags} //= []; push @{$rToUpdate{$lFlags}}, $rUID; } else { # conflict msg(undef, "WARNING: Missed flag update in ", mbx_pretty($mailbox), " for (lUID,rUID) = ($lUID,$rUID). Repairing.") if $lModSeq <= $cache->{lHIGHESTMODSEQ} and $rModSeq <= $cache->{rHIGHESTMODSEQ}; # set both $lUID and $rUID to the union of $lFlags and $rFlags my $flags = flag_conflict($mailbox, $lUID => $lFlags, $rUID => $rFlags); $lToUpdate{$flags} //= []; push @{$lToUpdate{$flags}}, $lUID; $rToUpdate{$flags} //= []; push @{$rToUpdate{$flags}}, $rUID; } } elsif (!defined $lModified->{$lUID} and !defined $rModified->{$rUID}) { push @delete_mapping, $lUID; msg(undef, "WARNING: Pair (lUID,rUID) = ($lUID,$rUID) vanished from ", mbx_pretty($mailbox), ". Repairing.") unless $lVanished{$lUID} and $rVanished{$rUID}; } elsif (!defined $lModified->{$lUID}) { push @delete_mapping, $lUID; if ($lVanished{$lUID}) { push @rToRemove, $rUID; } else { msg2(local => $mailbox, "WARNING: UID $lUID disappeared. Redownloading remote UID $rUID."); push @rMissing, $rUID; } } elsif (!defined $rModified->{$rUID}) { push @delete_mapping, $lUID; if ($rVanished{$rUID}) { push @lToRemove, $lUID; } else { msg2(remote => $mailbox, "WARNING: UID $rUID disappeared. Redownloading local UID $lUID."); push @lMissing, $lUID; } } delete $lModified->{$lUID}; delete $lVanished{$lUID}; delete $rModified->{$rUID}; delete $rVanished{$rUID}; } # remove messages on the IMAP side; will increase HIGHESTMODSEQ $lIMAP->remove_message(@lToRemove) if @lToRemove; $rIMAP->remove_message(@rToRemove) if @rToRemove; # remove entries in the table delete_mapping($idx, $_) foreach @delete_mapping; $DBH->commit() if @delete_mapping; # push flag updates; will increase HIGHESTMODSEQ while (my ($lFlags,$lUIDs) = each %lToUpdate) { $lIMAP->push_flag_updates($lFlags, @$lUIDs); } while (my ($rFlags,$rUIDs) = each %rToUpdate) { $rIMAP->push_flag_updates($rFlags, @$rUIDs); } # Process UID found in IMAP but not in the mapping table. my @lDunno = keys %lVanished; my @rDunno = keys %rVanished; msg2(remote => $mailbox, "WARNING: No match for ".($#lDunno+1)." vanished local UID(s) " .compact_set(@lDunno).". Ignoring.") if @lDunno; msg2(local => $mailbox, "WARNING: No match for ".($#rDunno+1)." vanished remote UID(s) " .compact_set(@rDunno).". Ignoring.") if @rDunno; foreach my $lUID (keys %$lModified) { msg2(remote => $mailbox, "WARNING: No match for modified local UID $lUID. Redownloading."); push @lMissing, $lUID; } foreach my $rUID (keys %$rModified) { msg2(local => $mailbox, "WARNING: No match for modified remote UID $rUID. Redownloading."); push @rMissing, $rUID; } # download missing UIDs; will increase UIDNEXT and HIGHESTMODSEQ my @rIgnore = download_missing($idx, $mailbox, 'local', @lMissing) if @lMissing; my @lIgnore = download_missing($idx, $mailbox, 'remote', @rMissing) if @rMissing; # download new messages; this will also update UIDNEXT and HIGHESTMODSEQ in the database sync_messages($idx, $mailbox, \@lIgnore, \@rIgnore); } # Sync known messages. Since pull_updates is the last method call on # $lIMAP and $rIMAP, it is safe to call get_cache on either object after # this function, in order to update the HIGHESTMODSEQ. # Return true if an update was detected, and false otherwise sub sync_known_messages($$) { my ($idx, $mailbox) = @_; my $update = 0; # Find local/remote UID from the mapping table. state $sth_get_local_uid = $DBH->prepare(q{ SELECT lUID FROM mapping WHERE idx = ? and rUID = ? }); state $sth_get_remote_uid = $DBH->prepare(q{ SELECT rUID FROM mapping WHERE idx = ? and lUID = ? }); # loop since processing might produce VANISHED or unsolicited FETCH responses while (1) { my ($lVanished, $lModified, $rVanished, $rModified); ($lVanished, $lModified) = $lIMAP->pull_updates(); ($rVanished, $rModified) = $rIMAP->pull_updates(); # repeat until we have nothing pending return $update unless %$lModified or %$rModified or @$lVanished or @$rVanished; $update = 1; # process VANISHED messages # /!\ this might modify the VANISHED or MODIFIED cache! if (@$lVanished or @$rVanished) { my %lVanished = map {$_ => 1} @$lVanished; my %rVanished = map {$_ => 1} @$rVanished; # For each vanished UID, get the corresponding one on the # other side (from the DB); consider it as to be removed if # it hasn't been removed already. my (@lToRemove, @rToRemove, @lDunno, @rDunno); foreach my $lUID (@$lVanished) { $sth_get_remote_uid->bind_param(1, $idx, SQL_INTEGER); $sth_get_remote_uid->bind_param(2, $lUID, SQL_INTEGER); $sth_get_remote_uid->execute(); my ($rUID) = $sth_get_remote_uid->fetchrow_array(); die if defined $sth_get_remote_uid->fetch(); # safety check if (!defined $rUID) { push @lDunno, $lUID; } elsif (!exists $rVanished{$rUID}) { push @rToRemove, $rUID; } } foreach my $rUID (@$rVanished) { $sth_get_local_uid->bind_param(1, $idx, SQL_INTEGER); $sth_get_local_uid->bind_param(2, $rUID, SQL_INTEGER); $sth_get_local_uid->execute(); my ($lUID) = $sth_get_local_uid->fetchrow_array(); die if defined $sth_get_local_uid->fetch(); # safety check if (!defined $lUID) { push @rDunno, $rUID; } elsif (!exists $lVanished{$lUID}) { push @lToRemove, $lUID; } } msg2(remote => $mailbox, "WARNING: No match for ".($#lDunno+1)." vanished local UID(s) " .compact_set(@lDunno).". Ignoring.") if @lDunno; msg2(local => $mailbox, "WARNING: No match for ".($#rDunno+1)." vanished remote UID(s) " .compact_set(@rDunno).". Ignoring.") if @rDunno; $lIMAP->remove_message(@lToRemove) if @lToRemove; $rIMAP->remove_message(@rToRemove) if @rToRemove; # remove existing mappings foreach my $lUID (@$lVanished, @lToRemove) { delete_mapping($idx, $lUID); } } # process FLAG updates # /!\ this might modify the VANISHED or MODIFIED cache! if (%$lModified or %$rModified) { my (%lToUpdate, %rToUpdate); # Take flags updates on both sides, and get the # corresponding UIDs on the other side (from the DB). # If it wasn't modified there, make it such; if it was # modified with the same flags list, ignore that message; # otherwise there is a conflict, and take the union. # # Group by flags in order to limit the number of round # trips. while (my ($lUID,$lFlags) = each %$lModified) { $sth_get_remote_uid->bind_param(1, $idx, SQL_INTEGER); $sth_get_remote_uid->bind_param(2, $lUID, SQL_INTEGER); $sth_get_remote_uid->execute(); my ($rUID) = $sth_get_remote_uid->fetchrow_array(); die if defined $sth_get_remote_uid->fetch(); # safety check if (!defined $rUID) { msg2(remote => $mailbox, "WARNING: No match for modified local UID $lUID. Try '--repair'."); } elsif (defined (my $rFlags = $rModified->{$rUID})) { unless ($lFlags eq $rFlags) { my $flags = flag_conflict($mailbox, $lUID => $lFlags, $rUID => $rFlags); $lToUpdate{$flags} //= []; push @{$lToUpdate{$flags}}, $lUID; $rToUpdate{$flags} //= []; push @{$rToUpdate{$flags}}, $rUID; } } else { $rToUpdate{$lFlags} //= []; push @{$rToUpdate{$lFlags}}, $rUID; } } while (my ($rUID,$rFlags) = each %$rModified) { $sth_get_local_uid->bind_param(1, $idx, SQL_INTEGER); $sth_get_local_uid->bind_param(2, $rUID, SQL_INTEGER); $sth_get_local_uid->execute(); my ($lUID) = $sth_get_local_uid->fetchrow_array(); die if defined $sth_get_local_uid->fetch(); # safety check if (!defined $lUID) { msg2(local => $mailbox, "WARNING: No match for modified remote UID $rUID. Try '--repair'."); } elsif (!exists $lModified->{$lUID}) { # conflicts are taken care of above $lToUpdate{$rFlags} //= []; push @{$lToUpdate{$rFlags}}, $lUID; } } while (my ($lFlags,$lUIDs) = each %lToUpdate) { $lIMAP->push_flag_updates($lFlags, @$lUIDs); } while (my ($rFlags,$rUIDs) = each %rToUpdate) { $rIMAP->push_flag_updates($rFlags, @$rUIDs); } } } } # The callback to use when FETCHing new messages from $name to add it to # the other one. # If defined, the array reference $UIDs will be fed with the newly added # UIDs. # If defined, $buff contains the list of messages to be appended with # MULTIAPPEND. In that case callback_new_message_flush should be called # after the FETCH. sub callback_new_message($$$$;$$$) { my ($idx, $mailbox, $name, $mail, $UIDs, $buff, $bufflen) = @_; return unless exists $mail->{RFC822}; # not for us my $length = length(${$mail->{RFC822}} // ""); if ($length == 0) { # the RFC822 attribute can be NIL or empty (it's an nstring), however # NIL can't be used in APPEND commands, and RFC 3502 sec. 6.3.11 # explicitly forbids zero-length messages, so we ignore these here msg2($name => $mailbox, "WARNING: Ignoring new 0-length message (UID $mail->{UID})"); return; } my @UIDs; unless (defined $buff) { @UIDs = callback_new_message_flush($idx, $mailbox, $name, $mail); } else { # use MULTIAPPEND (RFC 3502) # proceed by batches of 128/1MiB to save roundtrips without blowing up the memory if ($#$buff >= 127 or (@$buff and $$bufflen + $length > 1048576)) { @UIDs = callback_new_message_flush($idx, $mailbox, $name, @$buff); @$buff = (); $$bufflen = 0; } push @$buff, $mail; $$bufflen += $length; } push @$UIDs, @UIDs if defined $UIDs; } # Add the given @messages (multiple messages are only allowed for # MULTIAPPEND-capable servers) from $name to the other server. # Returns the list of newly allocated UIDs. sub callback_new_message_flush($$$@) { my ($idx, $mailbox, $name, @messages) = @_; my $target = $name eq "local" ? "remote" : "local"; my $imap = $target eq "local" ? $lIMAP : $rIMAP; # target client my @sUID = map {$_->{UID}} @messages; my @tUID = $imap->append(mbx_name($target, $mailbox), @messages); die unless $#sUID == $#tUID; # sanity check state $sth = $DBH->prepare(q{ INSERT INTO mapping (idx,lUID,rUID) VALUES (?,?,?) }); my ($lUIDs, $rUIDs) = $name eq 'local' ? (\@sUID,\@tUID) : (\@tUID,\@sUID); for (my $k=0; $k<=$#messages; $k++) { logger(undef, "Adding mapping (lUID,rUID) = ($lUIDs->[$k],$rUIDs->[$k]) for ", mbx_pretty($mailbox)) if $CONFIG{debug}; $sth->bind_param(1, $idx, SQL_INTEGER); $sth->bind_param(2, $lUIDs->[$k], SQL_INTEGER); $sth->bind_param(3, $rUIDs->[$k], SQL_INTEGER); $sth->execute(); } $DBH->commit(); # commit only once per batch return @tUID; } # Sync both known and new messages # If the array references $lIgnore and $rIgnore are not empty, skip # the given UIDs. sub sync_messages($$;$$) { my ($idx, $mailbox, $lIgnore, $rIgnore) = @_; my %ignore = (local => ($lIgnore // []), remote => ($rIgnore // [])); my $loop; do { # get new messages from $source (except @{$ignore{$source}}) and APPEND them to $target foreach my $source (qw/remote local/) { # pull remote mails first my $target = $source eq 'remote' ? 'local' : 'remote'; my $buff = [] unless ($target eq 'local' ? $lIMAP : $rIMAP)->incapable('MULTIAPPEND'); my $bufflen = 0; my @tUIDs; ($source eq 'remote' ? $rIMAP : $lIMAP)->pull_new_messages($ATTRS, sub($) { callback_new_message($idx, $mailbox, $source, shift, \@tUIDs, $buff, \$bufflen) }, @{$ignore{$source}}); push @tUIDs, callback_new_message_flush($idx, $mailbox, $source, @$buff) if defined $buff and @$buff; push @{$ignore{$target}}, @tUIDs; $loop = @tUIDs ? 1 : 0; } # since $source modifies $target's UIDNEXT upon new mails, we # need to check again the first $source (remote) whenever the # last one (local) added new messages to it } while ($loop); # both local and remote UIDNEXT are now up to date; proceed with # pending flag updates and vanished messages sync_known_messages($idx, $mailbox); # don't store the new UIDNEXTs before to avoid downloading these # mails again in the event of a crash state $sth_update_local = $DBH->prepare(q{ UPDATE local SET UIDNEXT = ?, HIGHESTMODSEQ = ? WHERE idx = ? }); state $sth_update_remote = $DBH->prepare(q{ UPDATE remote SET UIDNEXT = ?, HIGHESTMODSEQ = ? WHERE idx = ? }); my ($lUIDNEXT, $lHIGHESTMODSEQ) = $lIMAP->get_cache(qw/UIDNEXT HIGHESTMODSEQ/); $sth_update_local->bind_param(1, $lUIDNEXT, SQL_INTEGER); $sth_update_local->bind_param(2, sprintf("%lld", $lHIGHESTMODSEQ), SQL_BIGINT); $sth_update_local->bind_param(3, $idx, SQL_INTEGER); $sth_update_local->execute(); my ($rUIDNEXT, $rHIGHESTMODSEQ) = $rIMAP->get_cache(qw/UIDNEXT HIGHESTMODSEQ/); $sth_update_remote->bind_param(1, $rUIDNEXT, SQL_INTEGER); $sth_update_remote->bind_param(2, sprintf("%lld", $rHIGHESTMODSEQ), SQL_BIGINT); $sth_update_remote->bind_param(3, $idx, SQL_INTEGER); $sth_update_remote->execute(); $DBH->commit(); } ############################################################################# # Resume interrupted mailbox syncs (before initializing the cache). # my ($MAILBOX, $IDX); # current mailbox, and its index in our database sub db_get_cache_by_idx($) { my $idx = shift; state $sth = $DBH->prepare(q{ SELECT l.UIDVALIDITY AS lUIDVALIDITY, l.UIDNEXT AS lUIDNEXT, l.HIGHESTMODSEQ AS lHIGHESTMODSEQ, r.UIDVALIDITY AS rUIDVALIDITY, r.UIDNEXT AS rUIDNEXT, r.HIGHESTMODSEQ AS rHIGHESTMODSEQ FROM local l JOIN remote r ON l.idx = r.idx WHERE l.idx = ? }); $sth->bind_param(1, $idx, SQL_INTEGER); $sth->execute(); my $cache = $sth->fetchrow_hashref(); die if defined $sth->fetch(); # safety check if (defined $cache) { $cache->{$_} = sprintf("%llu", $cache->{$_}) foreach qw/lHIGHESTMODSEQ rHIGHESTMODSEQ/; } return $cache; } { # Get the list of interrupted mailbox syncs. my $sth_list = $DBH->prepare(q{ SELECT mbx.idx, mailbox FROM mailboxes mbx JOIN local l ON mbx.idx = l.idx JOIN remote r ON mbx.idx = r.idx JOIN mapping ON mbx.idx = mapping.idx WHERE (lUID >= l.UIDNEXT OR rUID >= r.UIDNEXT) GROUP BY mbx.idx }); # For an interrupted mailbox sync, get the pairs (lUID,rUID) that have # already been downloaded. my $sth_get_by_idx = $DBH->prepare(q{ SELECT lUID, rUID FROM mapping m JOIN local l ON m.idx = l.idx JOIN remote r ON m.idx = r.idx WHERE (lUID >= l.UIDNEXT OR rUID >= r.UIDNEXT) AND m.idx = ? }); $sth_list->execute(); while (defined (my $row = $sth_list->fetchrow_arrayref())) { next unless grep { $_ eq $row->[1] } @MAILBOXES; # skip ignored mailboxes ($IDX, $MAILBOX) = @$row; msg(undef, "Resuming interrupted sync for ", mbx_pretty($MAILBOX)); my $cache = db_get_cache_by_idx($IDX) // die; # safety check my ($lMailbox, $rMailbox) = map {mbx_name($_, $MAILBOX)} qw/local remote/; my %lUIDs; $sth_get_by_idx->bind_param(1, $IDX, SQL_INTEGER); $sth_get_by_idx->execute(); while (defined (my $row = $sth_get_by_idx->fetchrow_arrayref())) { $lUIDs{$row->[0]} = $row->[1]; # pair ($lUID, $rUID) } die unless %lUIDs; # sanity check $lIMAP->select($lMailbox); $rIMAP->select($rMailbox); # FETCH all messages with their FLAGS to detect messages that have # vanished meanwhile, or for which there was a flag update. my (%lList, %rList); # The lists of existing local and remote UIDs my $attrs = "(MODSEQ FLAGS)"; $lIMAP->fetch(compact_set(keys %lUIDs), $attrs, sub($){ $lList{shift->{UID}} = 1 }); $rIMAP->fetch(compact_set(values %lUIDs), $attrs, sub($){ $rList{shift->{UID}} = 1 }); my (@lToRemove, @rToRemove); while (my ($lUID,$rUID) = each %lUIDs) { next if $lList{$lUID} and $rList{$rUID}; # exists on both push @lToRemove, $lUID if $lList{$lUID}; push @rToRemove, $rUID if $rList{$rUID}; delete_mapping($IDX, $lUID); } $lIMAP->remove_message(@lToRemove) if @lToRemove; $rIMAP->remove_message(@rToRemove) if @rToRemove; $DBH->commit() if @lToRemove or @rToRemove; # /!\ commit *after* remove_message! # ignore deleted messages delete @lList{@lToRemove}; delete @rList{@rToRemove}; # Resume the sync, but skip messages that have already been # downloaded. Flag updates will be processed automatically since # the _MODIFIED internal cache has been initialized with all our # UIDs. (Since there is no reliable HIGHESTMODSEQ, any flag # difference is treated as a conflict.) $lIMAP->set_cache($lMailbox, UIDVALIDITY => $cache->{lUIDVALIDITY}, UIDNEXT => $cache->{lUIDNEXT} ); $rIMAP->set_cache($rMailbox, UIDVALIDITY => $cache->{rUIDVALIDITY}, UIDNEXT => $cache->{rUIDNEXT} ); sync_messages($IDX, $MAILBOX, [keys %lList], [keys %rList]); } } ############################################################################# # Initialize $lIMAP and $rIMAP states to detect mailbox dirtyness. # my %KNOWN_INDEXES; { # Get all cached states from the database. my $sth = $DBH->prepare(q{ SELECT mailbox, m.idx AS idx, l.UIDVALIDITY AS lUIDVALIDITY, l.UIDNEXT AS lUIDNEXT, l.HIGHESTMODSEQ AS lHIGHESTMODSEQ, r.UIDVALIDITY AS rUIDVALIDITY, r.UIDNEXT AS rUIDNEXT, r.HIGHESTMODSEQ AS rHIGHESTMODSEQ FROM mailboxes m JOIN local l ON m.idx = l.idx JOIN remote r ON m.idx = r.idx }); $sth->execute(); while (defined (my $row = $sth->fetchrow_hashref())) { next unless grep {$row->{mailbox} eq $_} @MAILBOXES; $lIMAP->set_cache(mbx_name(local => $row->{mailbox}), UIDVALIDITY => $row->{lUIDVALIDITY}, UIDNEXT => $row->{lUIDNEXT}, HIGHESTMODSEQ => sprintf("%llu", $row->{lHIGHESTMODSEQ}) ); $rIMAP->set_cache(mbx_name(remote => $row->{mailbox}), UIDVALIDITY => $row->{rUIDVALIDITY}, UIDNEXT => $row->{rUIDNEXT}, HIGHESTMODSEQ => sprintf("%llu", $row->{rHIGHESTMODSEQ}) ); $KNOWN_INDEXES{$row->{idx}} = 1; } } if (defined $COMMAND and $COMMAND eq 'repair') { repair($_) foreach @MAILBOXES; exit 0; } if ($CONFIG{notify}) { # Be notified of new messages with EXISTS/RECENT responses, but don't # receive unsolicited FETCH responses with a RFC822/BODY[]. It costs us an # extra roundtrip, but we need to sync FLAG updates and VANISHED responses # in batch mode, update the HIGHESTMODSEQ, and *then* issue an explicit UID # FETCH command to get new message, and process each FETCH response with a # RFC822/BODY[] attribute as they arrive. foreach my $name (qw/local remote/) { my $mailboxes = join(' ', map {Net::IMAP::InterIMAP::quote(mbx_name($name, $_))} @MAILBOXES); my %mailboxes = map { $_ => [qw/MessageNew MessageExpunge FlagChange/] } ( "MAILBOXES ($mailboxes)", 'SELECTED' ); my %personal = ( personal => [qw/MailboxName SubscriptionChange/] ); my $imap = $name eq "local" ? $lIMAP : $rIMAP; # require STATUS responses for our @MAILBOXES only $imap->notify('SET STATUS', %mailboxes); $imap->notify('SET', %mailboxes, %personal); } } sub loop() { state $sth_insert_local = $DBH->prepare(q{ INSERT INTO local (idx,UIDVALIDITY,UIDNEXT,HIGHESTMODSEQ) VALUES (?,?,0,0) }); state $sth_insert_remote = $DBH->prepare(q{ INSERT INTO remote (idx,UIDVALIDITY,UIDNEXT,HIGHESTMODSEQ) VALUES (?,?,0,0) }); state $sth_update_local_highestmodseq = $DBH->prepare(q{ UPDATE local SET HIGHESTMODSEQ = ? WHERE idx = ? }); state $sth_update_remote_highestmodseq = $DBH->prepare(q{ UPDATE remote SET HIGHESTMODSEQ = ? WHERE idx = ? }); while(@MAILBOXES) { if (defined $MAILBOX and ($lIMAP->is_dirty(mbx_name(local => $MAILBOX)) or $rIMAP->is_dirty(mbx_name(remote => $MAILBOX)))) { # $MAILBOX is dirty on either the local or remote mailbox sync_messages($IDX, $MAILBOX); } else { $MAILBOX = mbx_unname(local => $lIMAP->next_dirty_mailbox(map {mbx_name(local => $_)} @MAILBOXES)) // mbx_unname(remote => $rIMAP->next_dirty_mailbox(map {mbx_name(remote => $_)} @MAILBOXES)) // last; $IDX = db_get_mailbox_idx($MAILBOX) // die; # safety check select_mbx($IDX, $MAILBOX); if (!$KNOWN_INDEXES{$IDX}) { my $lUIDVALIDITY = $lIMAP->uidvalidity(mbx_name(local => $MAILBOX)); $sth_insert_local->bind_param(1, $IDX, SQL_INTEGER); $sth_insert_local->bind_param(2, $lUIDVALIDITY, SQL_INTEGER); $sth_insert_local->execute(); my $rUIDVALIDITY = $rIMAP->uidvalidity(mbx_name(remote => $MAILBOX)); $sth_insert_remote->bind_param(1, $IDX, SQL_INTEGER); $sth_insert_remote->bind_param(2, $rUIDVALIDITY, SQL_INTEGER); $sth_insert_remote->execute(); # no need to commit before the first mapping (lUID,rUID) $KNOWN_INDEXES{$IDX} = 1; } elsif (sync_known_messages($IDX, $MAILBOX)) { # sync updates to known messages before fetching new messages # get_cache is safe after pull_update my $lHIGHESTMODSEQ = sprintf "%lld", $lIMAP->get_cache(qw/HIGHESTMODSEQ/); $sth_update_local_highestmodseq->bind_param(1, $lHIGHESTMODSEQ, SQL_BIGINT); $sth_update_local_highestmodseq->bind_param(2, $IDX, SQL_INTEGER); $sth_update_local_highestmodseq->execute(); my $rHIGHESTMODSEQ = sprintf "%lld", $rIMAP->get_cache(qw/HIGHESTMODSEQ/); $sth_update_remote_highestmodseq->bind_param(1, $rHIGHESTMODSEQ, SQL_BIGINT); $sth_update_remote_highestmodseq->bind_param(2, $IDX, SQL_INTEGER); $sth_update_remote_highestmodseq->execute(); $DBH->commit(); } sync_messages($IDX, $MAILBOX); } } } sub notify(@) { # TODO: interpret LIST responses to detect mailbox # creation/deletion/subcription/unsubscription # mailbox creation # * LIST () "/" test # mailbox subscribtion # * LIST (\Subscribed) "/" test # mailbox unsubscribtion # * LIST () "/" test # mailbox renaming # * LIST () "/" test2 ("OLDNAME" (test)) # mailbox deletion # * LIST (\NonExistent) "/" test2 unless (Net::IMAP::InterIMAP::slurp(\@_, $CONFIG{watch}, \&Net::IMAP::InterIMAP::is_dirty)) { $_->noop() foreach @_; } } unless (defined $CONFIG{watch}) { loop(); exit 0; } while (1) { loop(); if ($CONFIG{notify}) { notify($lIMAP, $rIMAP); } else { # we need to issue a NOOP command or go back to AUTH state since the # LIST command may not report the correct HIGHESTMODSEQ value for # the mailbox currently selected # RFC3501: "The STATUS command MUST NOT be used as a "check for # new messages in the selected mailbox" operation" if (defined $MAILBOX) { # Prefer UNSELECT over NOOP commands as it requires a single command per cycle if ($lIMAP->incapable('UNSELECT') or $rIMAP->incapable('UNSELECT')) { $_->noop() foreach ($lIMAP, $rIMAP); } else { $_->unselect() foreach ($lIMAP, $rIMAP); undef $MAILBOX; } } sleep $CONFIG{watch}; # refresh the mailbox list and status list_mailboxes($_) for qw/local remote/; @MAILBOXES = sync_mailbox_list(); } } END { cleanup(); } interimap-0.5.8/interimap.sample 0000664 0000000 0000000 00000001142 15003201720 0016673 0 ustar 00root root 0000000 0000000 #database = imap.example.net.db # only consider subscribed mailboxes list-select-opts = SUBSCRIBED #list-mailbox = "*" # ignore the mailbox named 'virtual' and its descendants ignore-mailbox = ^virtual(?:\x00|$) [local] type = tunnel command = doveadm exec imap null-stderr = YES [remote] #type = imaps host = imap.example.net #port = 993 #proxy = socks5h://localhost:9050 username = guilhem password = xxxxxxxxxxxxxxxx #compress = YES # SSL options #SSL_verify = YES #SSL_protocol_min = TLSv1.2 #SSL_fingerprint = sha256$29111aea5d5be7e448bdc5c6e8a9d03bc9221c53c09b1cfbe6f953221e24dda0 # vim:ft=dosini interimap-0.5.8/interimap.service 0000664 0000000 0000000 00000000545 15003201720 0017060 0 ustar 00root root 0000000 0000000 [Unit] Description=Fast bidirectional synchronization for QRESYNC-capable IMAP servers Documentation=man:interimap(1) Documentation=https://guilhem.org/interimap/interimap.1.html Wants=network-online.target After=network-online.target [Service] ExecStart=@bindir@/interimap --watch=60 RestartSec=10min Restart=on-failure [Install] WantedBy=default.target interimap-0.5.8/interimap@.service 0000664 0000000 0000000 00000000630 15003201720 0017153 0 ustar 00root root 0000000 0000000 [Unit] Description=Fast bidirectional synchronization for QRESYNC-capable IMAP servers (instance %i) Documentation=man:interimap(1) Documentation=https://guilhem.org/interimap/interimap.1.html PartOf=interimap.service Wants=network-online.target After=network-online.target [Service] ExecStart=@bindir@/interimap --config=%i --watch=60 RestartSec=10min Restart=on-failure [Install] WantedBy=default.target interimap-0.5.8/lib/ 0000775 0000000 0000000 00000000000 15003201720 0014250 5 ustar 00root root 0000000 0000000 interimap-0.5.8/lib/Net/ 0000775 0000000 0000000 00000000000 15003201720 0014776 5 ustar 00root root 0000000 0000000 interimap-0.5.8/lib/Net/IMAP/ 0000775 0000000 0000000 00000000000 15003201720 0015524 5 ustar 00root root 0000000 0000000 interimap-0.5.8/lib/Net/IMAP/InterIMAP.pm 0000664 0000000 0000000 00000306320 15003201720 0017616 0 ustar 00root root 0000000 0000000 #---------------------------------------------------------------------- # A minimal IMAP4 client for QRESYNC-capable servers # Copyright © 2015-2022 Guilhem Moulin # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . #---------------------------------------------------------------------- package Net::IMAP::InterIMAP v0.5.8; use v5.20.0; use warnings; use strict; use Compress::Raw::Zlib qw/Z_OK Z_STREAM_END Z_FULL_FLUSH Z_SYNC_FLUSH MAX_WBITS/; use Config::Tiny (); use Errno qw/EEXIST EINTR/; use Net::SSLeay 1.86_06 (); use List::Util qw/all first/; use POSIX ':signal_h'; use Socket qw/SOCK_STREAM SOCK_RAW SOCK_CLOEXEC IPPROTO_TCP SHUT_RDWR AF_UNIX AF_INET AF_INET6 PF_UNSPEC :addrinfo/; use Exporter 'import'; BEGIN { Net::SSLeay::load_error_strings(); Net::SSLeay::SSLeay_add_ssl_algorithms(); Net::SSLeay::randomize(); our @EXPORT_OK = qw/xdg_basedir read_config compact_set slurp is_dirty has_new_mails/; } # Regexes for RFC 3501's 'ATOM-CHAR', 'ASTRING-CHAR', 'list-char' and 'TEXT-CHAR'. my $RE_ATOM_CHAR = qr/[\x21\x23\x24\x26\x27\x2B-\x5B\x5E-\x7A\x7C-\x7E]/; my $RE_ASTRING_CHAR = qr/[\x21\x23\x24\x26\x27\x2B-\x5B\x5D-\x7A\x7C-\x7E]/; my $RE_LIST_CHAR = qr/[\x21\x23-\x27\x2A\x2B-\x5B\x5D-\x7A\x7C-\x7E]/; my $RE_TEXT_CHAR = qr/[\x01-\x09\x0B\x0C\x0E-\x7F]/; my $RE_SSL_PROTO = qr/(?:SSLv[23]|TLSv1|TLSv1\.[0-3])/; # Map each option to a regexp validating its values. my %OPTIONS = ( host => qr/\A(\P{Control}+)\z/, port => qr/\A(\P{Control}+)\z/, proxy => qr/\A(\P{Control}+)\z/, type => qr/\A(imaps?|tunnel)\z/, STARTTLS => qr/\A(YES|NO)\z/i, username => qr/\A([\x01-\x7F]+)\z/, password => qr/\A([\x01-\x7F]+)\z/, auth => qr/\A($RE_ATOM_CHAR+(?: $RE_ATOM_CHAR+)*)\z/, command => qr/\A(\P{Control}+)\z/, 'null-stderr' => qr/\A(YES|NO)\z/i, compress => qr/\A(YES|NO)\z/i, SSL_protocols => qr/\A(!?$RE_SSL_PROTO(?: !?$RE_SSL_PROTO)*)\z/, # TODO deprecated, remove in 0.6 SSL_protocol_min => qr/\A(\P{Control}+)\z/, SSL_protocol_max => qr/\A(\P{Control}+)\z/, SSL_fingerprint => qr/\A((?:[A-Za-z0-9]+\$)?\p{AHex}+(?: (?:[A-Za-z0-9]+\$)?\p{AHex}+)*)\z/, SSL_cipherlist => qr/\A(\P{Control}+)\z/, SSL_ciphersuites => qr/\A(\P{Control}*)\z/, # "an empty list is permissible" SSL_hostname => qr/\A(\P{Control}*)\z/, SSL_verify => qr/\A(YES|NO)\z/i, SSL_CApath => qr/\A(\P{Control}+)\z/, SSL_CAfile => qr/\A(\P{Control}+)\z/, ); # Use the same buffer size as Net::SSLeay::read(), to ensure there is # never any pending data left in the current TLS record my $BUFSIZE = 32768; my $CRLF = "\x0D\x0A"; ############################################################################# # Utilities # xdg_basedir($xdg_variable, $default, $subdir, $path) # Return $path if $path is absolute. Otherwise, return # "$ENV{$xdg_variable}/$subdir/$path" (resp. "~/$default/$subdir/path" # if the "$xdg_variable" environment variable is not set). # An error is raised if "$ENV{$xdg_variable}" (resp. "~/$default") is # not an existing absolute directory. # If "$ENV{$xdg_variable}/$subdir" doesn't exist, it is created with # mode 0700. sub xdg_basedir($$$$) { my ($xdg_variable, $default, $subdir, $path) = @_; $path =~ /\A(\p{Print}+)\z/ or die "Insecure $path"; $path = $1; return $path if $path =~ /\A\//; my $basedir = $ENV{$xdg_variable}; $basedir = ($ENV{HOME} // "") ."/". $default unless defined $basedir; die "No such directory: ", $basedir unless -d $basedir; $basedir .= "/".$subdir; $basedir =~ /\A(\/\p{Print}+)\z/ or die "Insecure $basedir"; $basedir = $1; unless (mkdir ($basedir, 0700)) { die "Couldn't create $basedir: $!\n" unless $! == EEXIST; } return $basedir ."/". $path; } # read_config($conffile, $sections, %opts) # Read $conffile's default section, then each section in the array # reference $section (which takes precedence). %opts extends %OPTIONS # and maps each option to a regexp validating its values. sub read_config($$%) { my $conffile = shift; my $sections = shift; my %opts = (%OPTIONS, @_); die "No such config file $conffile\n" unless defined $conffile and -f $conffile and -r $conffile; my $h = Config::Tiny::->read($conffile); my %configs; foreach my $section (@$sections) { my $conf = defined $h->{_} ? { %{$h->{_}} } : {}; # default section $configs{$section} = $conf; if ($section ne '_') { die "No such section $section\n" unless defined $h->{$section}; $conf->{$_} = $h->{$section}->{$_} foreach keys %{$h->{$section}}; } # default values $conf->{type} //= 'imaps'; $conf->{host} //= 'localhost'; $conf->{port} //= $conf->{type} eq 'imaps' ? 993 : $conf->{type} eq 'imap' ? 143 : undef; $conf->{auth} //= 'PLAIN LOGIN'; $conf->{STARTTLS} //= 'YES'; # untaint and validate the config foreach my $k (keys %$conf) { die "Invalid option $k\n" unless defined $opts{$k}; next unless defined $conf->{$k}; die "Invalid option $k = $conf->{$k}\n" unless $conf->{$k} =~ $opts{$k}; $conf->{$k} = $opts{$k} ne qr/\A(YES|NO)\z/i ? $1 : uc $1 eq 'YES' ? 1 : 0; } } return \%configs; } # compact_set(@set) # compact_list(@set) # Compact the UID or sequence number set @set, which must be # non-empty and may not contain '*'. # compact_set sorts the input UID list and removes duplicates, while # compact_list doesn't. sub compact_set(@) { my @set = sort {$a <=> $b} @_; my $min = my $max = shift @set // die 'Empty range'; my $set; while (@set) { my $k = shift @set; if ($k < $max) { die "Non-sorted range: $k < $max"; # sanity check } elsif ($k == $max) { # skip duplicates } elsif ($k == $max + 1) { $max++; } else { $set .= ',' if defined $set; $set .= $min == $max ? $min : "$min:$max"; $min = $max = $k; } } $set .= ',' if defined $set; $set .= $min == $max ? $min : "$min:$max"; return $set; } sub compact_list(@) { my $min = my $max = shift // die 'Empty range'; my ($set, $dir); while (@_) { my $k = shift; $dir //= $k < $max ? -1 : 1; if ($k != $max and $k == $max + $dir) { $max += $dir; } else { $set .= ',' if defined $set; $set .= $min == $max ? $min : "$min:$max"; $min = $max = $k; undef $dir; } } $set .= ',' if defined $set; $set .= $min == $max ? $min : "$min:$max"; return $set; } # with_set($set, $cmd) # Split long commands over multiple subsets to avoid exceeding the server limit sub with_set($&) { my ($set, $cmd) = @_; my $max_length = 4096; for (my $length = length($set); $length > $max_length;) { my $l = rindex($set, ',', $max_length); die unless $l > 0; # sanity check $cmd->(substr($set, 0, $l)); $set = substr($set, ++$l); $length -= $l; } return $cmd->($set); } # in_set($x, $set) # Return true if the UID or sequence number $x belongs to the set $set. # /!\ The highest number in the mailbox, "*" should not appear by # itself (other than in a range). sub in_set($$) { my ($x, $set) = @_; foreach my $r (split /,/, $set) { if ($r =~ /\A([0-9]+)\z/) { return 1 if $x == $1; } elsif ($r eq '*' or $r eq '*:*') { warn "Assuming $x belongs to set $set! (Dunno what \"*\" means.)"; return 1; } elsif ($r =~ /\A([0-9]+):\*\z/ or $r =~ /\A\*:([0-9]+)\z/) { return 1 if $1 <= $x; } elsif ($r =~ /\A([0-9]+):([0-9]+)\z/) { my ($min,$max) = $1 < $2 ? ($1,$2) : ($2,$1); return 1 if $min <= $x and $x <= $max; } } return 0; } # quote($str) # Quote the given string if needed, or make it a (synchronizing) # literal. The literals will later be made non-synchronizing if the # server is LITERAL+-capable (RFC 2088). sub quote($) { my $str = shift; if ($str =~ qr/\A$RE_ASTRING_CHAR+\z/) { return $str; } elsif ($str =~ qr/\A$RE_TEXT_CHAR*\z/) { $str =~ s/([\x22\x5C])/\\$1/g; return "\"$str\""; } else { # we'll later replace the non-synchronizing literal with a # synchronizing one if need be return "{".length($str)."+}$CRLF".$str; } } ############################################################################# # Public interface # /!\ While this module can be used with non QRESYNC-capable (or non # QRESYNC-enabled) servers, there is no internal cache mapping sequence # numbers to UIDs, so EXPUNGE responses are ignored. # The IMAP authentication ('OK'/'PREAUTH'), bye ('BYE') or status # ('OK'/'NO'/'BAD') condition for the last command issued. our $IMAP_cond; # The response text for the last command issued (prefixed with the status # condition but without the tag). our $IMAP_text; # Create a new Net::IMAP::InterIMAP object. Connect to the server, # upgrade to a secure connection (STARTTLS), LOGIN/AUTHENTICATE if needed, and # update the CAPABILITY list. # In addition to the %OPTIONS above, valid parameters include: # # - 'debug': Enable debug messages. # # - 'enable': An extension or array reference of extensions to ENABLE # (RFC 5161) after entering AUTH state. Croak if the server did not # advertise "ENABLE" in its CAPABILITY list or does not reply with # an untagged ENABLED response with all the given extensions. # # - 'name': An optional instance name to include in log messages. # # - 'logger-fd': An optional filehandle to use for debug output # (default: STDERR). # # - 'keepalive': Whether to enable sending of keep-alive messages. # (type=imap or type=imaps). # sub new($%) { my $class = shift; my $self = { @_ }; bless $self, $class; require 'Time/HiRes.pm' if defined $self->{'logger-fd'}; # the IMAP state: one of 'UNAUTH', 'AUTH', 'SELECTED' or 'LOGOUT' # (cf RFC 3501 section 3) $self->{_STATE} = ''; # in/out buffer counts and output stream $self->{_INCOUNT} = $self->{_INRAWCOUNT} = 0; $self->{_OUTCOUNT} = $self->{_OUTRAWCOUNT} = 0; $self->{_OUTBUF} = $self->{_INBUF} = undef; $self->{_LITPLUS} = ''; if ($self->{type} eq 'tunnel') { my $command = $self->{command} // $self->fail("Missing tunnel command"); socketpair($self->{S}, my $s, AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, PF_UNSPEC) or $self->panic("socketpair: $!"); my $pid = fork // $self->panic("fork: $!"); unless ($pid) { # children close($self->{S}) or $self->panic("close: $!"); open STDIN, '<&', $s or $self->panic("dup: $!"); open STDOUT, '>&', $s or $self->panic("dup: $!"); my $stderr2; if (($self->{'null-stderr'} // 0) and !($self->{debug} // 0)) { open $stderr2, '>&', *STDERR; open STDERR, '>', '/dev/null' or $self->panic("open(/dev/null): $!"); } my $sigset = POSIX::SigSet::->new(SIGINT); my $oldsigset = POSIX::SigSet::->new(); sigprocmask(SIG_BLOCK, $sigset, $oldsigset) // $self->panic("sigprocmask: $!"); unless (exec $command) { my $err = $!; if (defined $stderr2) { close STDERR; open STDERR, '>&', $stderr2; } $self->panic("exec: $err"); } } # parent close($s) or $self->panic("close: $!"); } else { foreach (qw/host port/) { $self->fail("Missing option $_") unless defined $self->{$_}; } $self->{S} = defined $self->{proxy} ? $self->_proxify(@$self{qw/proxy host port/}) : $self->_tcp_connect(@$self{qw/host port/}); if (defined $self->{keepalive}) { setsockopt($self->{S}, Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, 1) or $self->fail("setsockopt SO_KEEPALIVE: $!"); setsockopt($self->{S}, Socket::IPPROTO_TCP, Socket::TCP_KEEPIDLE, 60) or $self->fail("setsockopt TCP_KEEPIDLE: $!"); } } binmode($self->{S}) // $self->panic("binmode: $!"); $self->_start_ssl($self->{S}) if $self->{type} eq 'imaps'; # command counter $self->{_TAG} = 0; # internal cache, constantly updated to reflect the current server # state for each mailbox $self->{_CACHE} = {}; # persistent cache, describing the last clean (synced) state $self->{_PCACHE} = {}; # list of UIDs for which the server a VANISHED or VANISHED (EARLIER) # response. /!\ requires a QRESYNC-capable server! # Only notifications with UID < $self->{_PCACHE}->{$mailbox}->{UIDNEXT} # are considered. $self->{_VANISHED} = []; # hash UID => [ MODSEQ, FLAGS ] for which the server a FETCH # response with the FLAGS attribute. The \Recent flag is always # omitted from the FLAG list. MODSEQ is always present, and the # value [ MODSEQ, FLAGS ] is updated if another FETCH response with # a higher MODSEQ is received. If FLAGS is undefined, then the FLAG # list of the message is considered unknown and should be retrieved # manually. # Only notifications with UID < $self->{_PCACHE}->{$mailbox}->{UIDNEXT} # and with MODSEQ => $self->{_PCACHE}->{$mailbox}->{HIGHESTMODSEQ} # are considered. $self->{_MODIFIED} = {}; # wait for the greeting my $x = $self->_getline(); $x =~ s/\A\* (OK|PREAUTH) // or $self->panic($x); $IMAP_cond = $1; $IMAP_text = $1.' '.$x; # try to update the cache (eg, capabilities) $self->_resp_text($x); if ($IMAP_cond eq 'OK') { # login required $self->{_STATE} = 'UNAUTH'; my @caps = $self->capabilities(); if ($self->{type} eq 'imap' and $self->{STARTTLS}) { # RFC 2595 section 5.1 $self->fail("Server did not advertise STARTTLS capability.") unless grep {$_ eq 'STARTTLS'} @caps; $self->_send('STARTTLS'); $self->_start_ssl($self->{S}); # refresh the previous CAPABILITY list since the previous one could have been spoofed delete $self->{_CAPABILITIES}; @caps = $self->capabilities(); } my @mechs = ('LOGIN', grep defined, map { /^AUTH=(.+)/i ? uc($1) : undef } @caps); my $mech = (grep defined, map {my $m = uc($_); (grep {$m eq $_} @mechs) ? $m : undef} split(/ /, $self->{auth}))[0]; $self->fail("Failed to choose an authentication mechanism") unless defined $mech; $self->fail("Logins are disabled.") if ($mech eq 'LOGIN' or $mech eq 'PLAIN') and grep {$_ eq 'LOGINDISABLED'} @caps; my ($command, $callback); my ($username, $password) = @$self{qw/username password/}; if ($mech eq 'LOGIN') { $self->fail("Missing option $_") foreach grep {!defined $self->{$_}} qw/username password/; $command = join ' ', 'LOGIN', quote($username), quote($password); } elsif ($mech eq 'PLAIN') { require 'MIME/Base64.pm'; $self->fail("Missing option $_") foreach grep {!defined $self->{$_}} qw/username password/; my $credentials = MIME::Base64::encode_base64("\x00".$username."\x00".$password, ''); $command = "AUTHENTICATE $mech"; if ($self->_capable('SASL-IR')) { # RFC 4959 SASL-IR $command .= " $credentials"; } else { $callback = sub($) {return $credentials}; } } else { $self->fail("Unsupported authentication mechanism: $mech"); } my $dbg; delete $self->{password}; # no need to remember passwords if (($self->{debug} // 0) == 1) { $dbg = $self->{debug}--; my $cmd = $command =~ /\A(LOGIN) / ? $1 : $command =~ /\A(AUTHENTICATE \S+)(?: .*)?\z/ ? $1 : $self->panic(); $self->logger('C: xxx ', $cmd, ' [REDACTED]'); } $self->_send($command, $callback); if (defined $dbg) { $self->logger('S: xxx ', $IMAP_text); $self->{debug} = $dbg; } $self->{_STATE} = 'AUTH'; unless ($IMAP_text =~ /\A\Q$IMAP_cond\E \[CAPABILITY /) { # refresh the CAPABILITY list since the previous one had only pre-login capabilities delete $self->{_CAPABILITIES}; $self->capabilities(); } } elsif ($IMAP_cond eq 'PREAUTH') { if ($self->{type} eq 'imap' and $self->{STARTTLS} != 0) { $self->fail("PREAUTH greeting on plaintext connection? MiTM in action? Aborting, set \"STARTTLS = NO\" to ignore."); } $self->{_STATE} = 'AUTH'; } else { $self->panic(); } # Don't send the COMPRESS command before STARTTLS or AUTH, as per RFC 4978 if ($self->{compress} // 1 and my @algos = grep defined, map { /^COMPRESS=(.+)/i ? uc $1 : undef } @{$self->{_CAPABILITIES}}) { my @supported = qw/DEFLATE/; # supported compression algorithms my $algo = first { my $x = $_; grep {$_ eq $x} @algos } @supported; if (!defined $algo) { $self->warn("Couldn't find a suitable compression algorithm. Not enabling compression."); } else { my ($d, $i); my $r = $self->_send("COMPRESS $algo"); unless ($r eq 'NO' and $IMAP_text =~ /\ANO \[COMPRESSIONACTIVE\] /) { $self->panic($IMAP_text) unless $r eq 'OK'; if ($algo eq 'DEFLATE') { my %args = ( -WindowBits => 0 - MAX_WBITS, -Bufsize => $BUFSIZE ); $self->{_Z_DEFLATE} = Compress::Raw::Zlib::Deflate::->new(%args, -AppendOutput => 1) // $self->panic("Can't create deflation stream"); $self->{_Z_INFLATE} = Compress::Raw::Zlib::Inflate::->new(%args) // $self->panic("Can't create inflation stream"); } else { $self->fail("Unsupported compression algorithm: $algo"); } } } } my @extensions = !defined $self->{enable} ? () : ref $self->{enable} eq 'ARRAY' ? @{$self->{enable}} : ($self->{enable}); if (@extensions) { $self->fail("Server did not advertise ENABLE (RFC 5161) capability.") unless $self->_capable('ENABLE'); $self->fail("Server did not advertise $_ capability.") foreach grep { !$self->_capable($_) } @extensions; $self->_send('ENABLE '.join(' ',@extensions)); my @enabled = @{$self->{_ENABLED} // []}; $self->fail("Couldn't ENABLE $_") foreach grep {my $e = $_; !grep {uc $e eq uc $_} @enabled} @extensions; } return $self; } # Print traffic statistics sub stats($) { my $self = shift; my $msg = 'IMAP traffic (bytes):'; $msg .= ' recv '._kibi($self->{_OUTCOUNT}); $msg .= ' (compr. '._kibi($self->{_OUTRAWCOUNT}). ', factor '.sprintf('%.2f', $self->{_OUTRAWCOUNT}/$self->{_OUTCOUNT}).')' if exists $self->{_Z_DEFLATE} and $self->{_OUTCOUNT} > 0; $msg .= ' sent '._kibi($self->{_INCOUNT}); $msg .= ' (compr. '._kibi($self->{_INRAWCOUNT}). ', factor '.sprintf('%.2f', $self->{_INRAWCOUNT}/$self->{_INCOUNT}).')' if exists $self->{_Z_DEFLATE} and $self->{_INCOUNT} > 0; $self->log($msg); } # Destroy a Net::IMAP::InterIMAP object. sub DESTROY($) { local($., $@, $!, $^E, $?); my $self = shift; $self->{_STATE} = 'LOGOUT'; Net::SSLeay::free($self->{_SSL}) if defined $self->{_SSL}; Net::SSLeay::CTX_free($self->{_SSL_CTX}) if defined $self->{_SSL_CTX}; if (defined (my $s = $self->{S})) { # for type=tunnel we assume the child won't linger around once # we close its standard input and output. shutdown($s, SHUT_RDWR); $s->close() if $s->opened(); } $self->stats() unless $self->{quiet}; } # $self->log($message, [...]) # $self->logger($message, [...]) # Log a $message. The latter method is used to log in the 'logger-fd' # (and adds timestamps). sub log($@) { my $self = shift; return unless @_; my $prefix = _logger_prefix($self); if (defined (my $fd = $self->{'logger-fd'})) { say $fd _date(), " ", $prefix, @_; } say STDERR $prefix, @_; } sub logger($@) { my $self = shift; return unless @_; my $prefix = _logger_prefix($self); if (defined (my $fd = $self->{'logger-fd'})) { say $fd _date(), " ", $prefix, @_; } else { say STDERR $prefix, @_; } } sub _date() { my ($s, $us) = Time::HiRes::gettimeofday(); my $t = POSIX::strftime("%b %e %H:%M:%S", localtime($s)); return "$t.$us"; # millisecond precision } # $self->_logger_prefix() # Format a prefix for logging with printf(3)-like sequences: # %n: the object name # %m: mailbox, either explicit named or selected sub _logger_prefix($) { my $self = shift; my $format = $self->{'logger-prefix'} // return ""; my %seq = ( "%" => "%", m => $self->{mailbox}, n => $self->{name} ); $seq{m} //= $self->{_SELECTED} // die if defined $self->{_STATE} and $self->{_STATE} eq 'SELECTED'; do {} while # rewrite conditionals (loop because of nesting) $format =~ s#%\? ([[:alpha:]]) \? ( (?: (?> (?: [^%&?\\] | %[^?] | \\[&?\\] )+ ) | (?R) )* ) (?: \& ( (?: (?> (?: [^%&?\\] | %[^?] | \\[&?\\] )+ ) | (?R) )*) )? \?# ($seq{$1} // "") ne "" ? $2 : ($3 // "") #agex; $format =~ s#\\([&?\\])#$1#g; # unescape remaining '&', '?' and '\' $format =~ s#%([%mn])# $seq{$1} #ge; return $format; } # $self->warn([$type,] $warning) # Log a $warning. sub warn($$;$) { my ($self, $msg, $t) = @_; $msg = defined $t ? "$msg WARNING: $t" : "WARNING: $msg"; $self->log($msg); } # $self->fail([$type,] $error) # Log an $error and exit with return value 1. sub fail($$;$) { my ($self, $msg, $t) = @_; $msg = defined $t ? "$msg ERROR: $t" : "ERROR: $msg"; $self->log($msg); exit 1; } # $self->panic($error, [...]) # Log a fatal $error including the position of the caller, and exit # with return value 255. sub panic($@) { my $self = shift; my @loc = caller; my $msg = "PANIC at line $loc[2] in $loc[1]"; $msg .= ': ' if @_; $self->log($msg, @_); exit 255; } # $self->capabilities() # Return the capability list of the IMAP4 server. The list is cached, # and a CAPABILITY command is only issued if the cache is empty. sub capabilities($) { my $self = shift; $self->_send('CAPABILITY') unless defined $self->{_CAPABILITIES} and @{$self->{_CAPABILITIES}}; $self->fail("Missing IMAP4rev1 CAPABILITY. Not an IMAP4 server?") unless $self->_capable('IMAP4rev1'); return @{$self->{_CAPABILITIES}}; } # $self->incapable(@capabilities) # In list context, return the list capabilties from @capabilities # which were NOT advertised by the server. In scalar context, return # the length of said list. sub incapable($@) { my ($self, @caps) = @_; my @mycaps = $self->capabilities(); grep {my $cap = uc $_; !grep {$cap eq uc $_} @mycaps} @caps; } # $self->search($criterion) # Issue a UID SEARCH command with the given $criterion. For the "normal" # UID SEARCH command from RFC 3501, return the list of matching UIDs; # for the extended UID SEARCH command from RFC 4731 (ensuring ESEARCH # capability is the caller's responsibility), return an optional "UID" # indicator followed by a hash containing search data pairs. sub search($$) { my ($self, $crit) = @_; my @res; $self->_send('UID SEARCH '.$crit, sub(@) {@res = @_}); return @res } # $self->select($mailbox, [$seqs, $UIDs]) # $self->examine($mailbox, [$seqs, $UIDs]) # Issue a SELECT or EXAMINE command for the $mailbox. Upon success, # change the state to SELECTED, otherwise go back to AUTH. # The optional $seqs and $UIDs are used as Message Sequence Match # Data for the QRESYNC parameter to the SELECT command. sub select($$;$$) { my $self = shift; my $mailbox = shift; $self->_select_or_examine('SELECT', $mailbox, @_); } sub examine($$;$$) { my $self = shift; my $mailbox = shift; $self->_select_or_examine('EXAMINE', $mailbox, @_); } # $self->unselect() # Issue an UNSELECT command (cf. RFC 3691). Upon success, change the # state to AUTH. sub unselect($) { my $self = shift; $self->_send('UNSELECT'); $self->{_STATE} = 'AUTH'; delete $self->{_SELECTED}; # it is safe to wipe cached VANISHED responses or FLAG updates, # because interesting stuff must have made the mailbox dirty so # we'll get back to it $self->{_VANISHED} = []; $self->{_MODIFIED} = {}; $self->{_NEW} = 0; } # $self->logout() # Issue a LOGOUT command. Change the state to LOGOUT. sub logout($) { my $self = shift; # don't bother if the connection is already closed $self->_send('LOGOUT') if $self->{S}->opened(); $self->{_STATE} = 'LOGOUT'; undef $self; } # $self->noop() # Issue a NOOP command. sub noop($) { shift->_send('NOOP'); } # $self->create($mailbox, [$try]) # $self->delete($mailbox, [$try]) # CREATE or DELETE $mailbox. # If try is set, print a warning but don't crash if the command fails. sub create($$;$) { my ($self, $mailbox, $try) = @_; my $r = $self->_send("CREATE ".quote($mailbox)); if ($IMAP_cond eq 'OK') { $self->log("Created mailbox ".$mailbox) unless $self->{quiet}; } else { my $msg = "Couldn't create mailbox ".$mailbox.': '.$IMAP_text; $try ? $self->warn($msg) : $self->fail($msg); } return $r; } sub delete($$;$) { my ($self, $mailbox, $try) = @_; my $r = $self->_send("DELETE ".quote($mailbox)); delete $self->{_CACHE}->{$mailbox}; delete $self->{_PCACHE}->{$mailbox}; if ($IMAP_cond eq 'OK') { $self->log("Deleted mailbox ".$mailbox) unless $self->{quiet}; } else { my $msg = "Couldn't delete mailbox ".$mailbox.': '.$IMAP_text; $try ? $self->warn($msg) : $self->fail($msg); } return $r; } # $self->rename($oldname, $newname, [$try]) # RENAME the mailbox $oldname to $newname. # If $try is set, print a warning but don't crash if the command fails. # /!\ Requires a LIST command to be issued to determine the hierarchy # delimiter and the mailbox attributes for the original name. sub rename($$$;$) { my ($self, $from, $to, $try) = @_; my ($delim, @attrs); if ($self->{_CACHE}->{$from}) { $delim = $self->{_CACHE}->{$from}->{DELIMITER}; @attrs = @{$self->{_CACHE}->{$from}->{LIST_ATTRIBUTES} // []}; } my $r = $self->_send("RENAME ".quote($from).' '.quote($to)); $self->{_CACHE}->{$to} = delete $self->{_CACHE}->{$from} if exists $self->{_CACHE}->{$from}; $self->{_PCACHE}->{$to} = delete $self->{_PCACHE}->{$from} if exists $self->{_PCACHE}->{$from}; if (defined $delim and !grep {lc $_ eq lc '\NoInferiors' or lc $_ eq lc '\HasNoChildren'} @attrs) { # on non-flat mailboxes, move children as well (cf 3501) foreach my $c1 (grep /\A\Q$from$delim\E/, keys %{$self->{_CACHE}}) { my $c2 = $c1 =~ s/\A\Q$from$delim\E/$to$delim/r; $self->{_CACHE}->{$c2} = delete $self->{_CACHE}->{$c1} if exists $self->{_CACHE}->{$c1}; $self->{_PCACHE}->{$c2} = delete $self->{_PCACHE}->{$c1} if exists $self->{_PCACHE}->{$c1}; } } if ($IMAP_cond eq 'OK') { $self->log("Renamed mailbox ".$from.' to '.$to) unless $self->{quiet}; } else { my $msg = "Couldn't rename mailbox ".$from.': '.$IMAP_text; $try ? $self->warn($msg) : $self->fail($msg); } return $r; } # $self->subscribe($mailbox, [$try]) # $self->unsubscribe($mailbox, [$try]) # SUBSCRIBE or UNSUBSCRIBE $mailbox. # If $try is set, print a warning but don't crash if the command fails. sub subscribe($$;$) { my ($self, $mailbox, $try) = @_; $mailbox = 'INBOX' if uc $mailbox eq 'INBOX'; # INBOX is case-insensitive my $r = $self->_send("SUBSCRIBE ".quote($mailbox)); if ($IMAP_cond eq 'OK') { $self->log("Subscribe to ".$mailbox) unless $self->{quiet}; } else { my $msg = "Couldn't subscribe to ".$mailbox.': '.$IMAP_text; $try ? $self->warn($msg) : $self->fail($msg); } return $r; } sub unsubscribe($$;$) { my ($self, $mailbox, $try) = @_; $mailbox = 'INBOX' if uc $mailbox eq 'INBOX'; # INBOX is case-insensitive my $r = $self->_send("UNSUBSCRIBE ".quote($mailbox)); if ($IMAP_cond eq 'OK') { $self->log("Unsubscribe to ".$mailbox) unless $self->{quiet}; } else { my $msg = "Couldn't unsubscribe to ".$mailbox.': '.$IMAP_text; $try ? $self->warn($msg) : $self->fail($msg); } return $r; } # $self->list($criterion, @parameters) # Issue a LIST command with the given $criterion and @parameters. # Return a pair where the first component is a hash reference of # matching mailboxes and their flags, and the second component is a # hash reference of matching mailboxes and their hierarchy delimiter # (or undef for flat mailboxes). sub list($$@) { my $self = shift; my $crit = shift; my %mailboxes; my %delims; $self->_send( "LIST ".$crit.(@_ ? (' RETURN ('.join(' ', @_).')') : ''), sub($$@) {my $name = shift; $delims{$name} = shift; $mailboxes{$name} = \@_;} ); return (\%mailboxes, \%delims); } # $self->remove_message($uid, [...]) # Remove the given $uid list. Croak if the server did not advertise # "UIDPLUS" (RFC 4315) in its CAPABILITY list. # Successfully EXPUNGEd UIDs are removed from the pending VANISHED and # MODIFIED lists. # Return the list of UIDs that couldn't be EXPUNGEd. sub remove_message($@) { my $self = shift; my @set = @_; $self->fail("Server did not advertise UIDPLUS (RFC 4315) capability.") unless $self->_capable('UIDPLUS'); with_set(compact_set(@set), sub($) { $self->_send("UID STORE $_[0] +FLAGS.SILENT (\\Deleted)"); $self->_send("UID EXPUNGE $_[0]"); # RFC 4315 UIDPLUS }); my %vanished = map {$_ => 1} @{$self->{_VANISHED}}; my (@failed, @expunged); foreach my $uid (@set) { if (exists $vanished{$uid}) { push @expunged, $uid } else { push @failed, $uid; } } # ignore succesfully EXPUNGEd messages delete @vanished{@expunged}; delete @{$self->{_MODIFIED}}{@expunged}; $self->{_VANISHED} = [ keys %vanished ]; $self->log("Removed ".($#expunged+1)." UID(s) ". compact_set(@expunged)) if @expunged and !$self->{quiet}; $self->warn("Couldn't UID EXPUNGE ".compact_set(@failed)) if @failed; return @failed; } # $self->append($mailbox, $mail, [...]) # Issue an APPEND command with the given mails. Croak if the server # did not advertise "UIDPLUS" (RFC 4315) in its CAPABILITY list. # Each $mail is a hash reference with key 'RFC822' and optionally # 'UID', 'FLAGS' and 'INTERNALDATE'. # Providing multiple mails is only allowed for servers supporting # "MULTIAPPEND" (RFC 3502). # Return the list of UIDs allocated for the new messages, in the order # they were APPENDed. sub append($$@) { my $self = shift; my $mailbox = shift; return unless @_; $self->fail("Server did not advertise UIDPLUS (RFC 4315) capability.") unless $self->_capable('UIDPLUS'); $self->fail("Server did not advertise MULTIAPPEND (RFC 3502) capability.") unless $#_ == 0 or $self->_capable('MULTIAPPEND'); # dump the cache before issuing the command if we're appending to the current mailbox my ($UIDNEXT, $EXISTS, $cache, %vanished); $mailbox = 'INBOX' if uc $mailbox eq 'INBOX'; # INBOX is case-insensitive if (defined $self->{_SELECTED} and $mailbox eq $self->{_SELECTED}) { $cache = $self->{_CACHE}->{$mailbox}; $UIDNEXT = $cache->{UIDNEXT} // $self->panic(); $EXISTS = $cache->{EXISTS} // $self->panic(); %vanished = map {$_ => 1} @{$self->{_VANISHED}}; } my $tag = $self->_cmd_init('APPEND '.quote($mailbox)); foreach my $mail (@_) { my $str = ' '; $str .= '('.join(' ', grep {lc $_ ne '\recent'} @{$mail->{FLAGS}}).') ' if defined $mail->{FLAGS}; $str .= '"'.$mail->{INTERNALDATE}.'" ' if defined $mail->{INTERNALDATE}; $self->_cmd_extend(\$str); $self->_cmd_extend_lit($mail->{RFC822} // $self->panic("Missing message body in APPEND")); } $self->_cmd_flush(); $self->_recv($tag); $IMAP_text =~ /\A\Q$IMAP_cond\E \[APPENDUID ([0-9]+) ([0-9:,]+)\] / or $self->panic($IMAP_text); my ($uidvalidity, $uidset) = ($1, $2); $self->_update_cache_for($mailbox, UIDVALIDITY => $uidvalidity); my @uids; foreach (split /,/, $uidset) { if (/\A([0-9]+)\z/) { $UIDNEXT = $1 + 1 if defined $UIDNEXT and $UIDNEXT <= $1; push @uids, $1; } elsif (/\A([0-9]+):([0-9]+)\z/) { my ($min, $max) = $1 <= $2 ? ($1,$2) : ($2,$1); push @uids, ($min .. $max); $UIDNEXT = $max + 1 if defined $UIDNEXT and $UIDNEXT <= $max; } else { $self->panic($_); } } $self->fail("$uidset contains ".scalar(@uids)." elements while ".($#_+1)." messages were appended.") unless $#uids == $#_; # if $mailbox is the current mailbox we need to update the cache if (defined $self->{_SELECTED} and $mailbox eq $self->{_SELECTED}) { # EXISTS responses SHOULD be sent by the server (per RFC3501), but it's not required my %vanished2 = map {$_ => 1} @{$self->{_VANISHED}}; delete $vanished2{$_} foreach keys %vanished; my $VANISHED = scalar(keys %vanished2); # number of messages VANISHED meanwhile $cache->{EXISTS} += $#_+1 if defined $cache->{EXISTS} and $cache->{EXISTS} + $VANISHED == $EXISTS; $cache->{UIDNEXT} = $UIDNEXT if ($cache->{UIDNEXT} // 1) < $UIDNEXT; } unless ($self->{quiet}) { my $msg = "Added ".($#_+1)." UID(s) "; $msg .= "to $mailbox " unless defined $self->{_SELECTED} and $mailbox eq $self->{_SELECTED}; if (defined $self->{name} and all {defined $_->{UID}} @_) { $msg .= $self->{name} eq 'local' ? (compact_list(@uids) .' <- '. compact_list(map {$_->{UID}} @_)) : (compact_list(map {$_->{UID}} @_) .' -> '. compact_list(@uids)); } else { $msg .= compact_list(@uids); } $self->log($msg); } return @uids; } # $self->fetch($set, $flags, [$callback]) # Issue a UID FETCH command with the given UID $set, $flags, and # optional $callback. sub fetch($$$;&) { my ($self, $set, $flags, $callback) = @_; return with_set($set, sub($) { $self->_send("UID FETCH $_[0] $flags", $callback); }); } # $self->notify($arg, %specifications) # Issue a NOTIFY command with the given $arg ("SET", "SET STATUS" or # "NONE") and mailbox %specifications (cf RFC 5465 section 6) to be # monitored. Croak if the server did not advertise "NOTIFY" (RFC # 5465) in its CAPABILITY list. sub notify($$@) { my $self = shift; $self->fail("Server did not advertise NOTIFY (RFC 5465) capability.") unless $self->_capable('NOTIFY'); my $command = 'NOTIFY '.shift; while (@_) { $command .= " (".shift." (".join(' ', @{shift()})."))"; } $self->_send($command); } # slurp($imap, $timeout, $stopwhen) # Keep reading untagged responses from the @$imap servers until the # $stopwhen condition becomes true (then return true), or until the # $timeout expires (then return false). # This is mostly useful when waiting for notifications while no # command is progress, cf. RFC 2177 (IDLE) or RFC 5465 (NOTIFY). sub slurp($$$) { my ($selfs, $timeout, $stopwhen) = @_; my $aborted = 0; my $rin = ''; vec($rin, fileno($_->{S}), 1) = 1 foreach @$selfs; while (1) { # first, consider only unprocessed data without our own output # buffer, or within the current TLS record: these would cause # select(2) to block/timeout due to the raw socket not being # ready. my @ready = grep { (defined $_->{_OUTBUF} and $_->{_OUTBUF} ne '') or (defined $_->{_SSL} and Net::SSLeay::pending($_->{_SSL}) > 0) } @$selfs; unless (@ready) { my ($r, $timeleft) = CORE::select(my $rout = $rin, undef, undef, $timeout); next if $r == -1 and $! == EINTR; # select(2) was interrupted die "select: $!" if $r == -1; return $aborted if $r == 0; # nothing more to read (timeout reached) @ready = grep {vec($rout, fileno($_->{S}), 1)} @$selfs; $timeout = $timeleft if $timeout > 0; } foreach my $imap (@ready) { my $x = $imap->_getline(); $imap->_resp($x, sub($;$$) { if ($stopwhen->($imap, @_)) { $aborted = 1; $timeout = 0; # keep reading the handles while there is pending data } }, 'slurp'); } } return $aborted; } # $self->idle($timeout, $stopwhen) # Enter IDLE (RFC 2177) for $timout seconds (by default 29 mins), or # when the callback $stopwhen returns true. # Return true if the callback returned true (either aborting IDLE, or # after the $timeout) and false otherwise. sub idle($$$) { my ($self, $timeout, $stopwhen) = @_; my $tag = $self->idle_start($timeout); my $r = slurp([$self], $timeout // 1740, $stopwhen); # 29 mins $r += $self->idle_stop($tag, $stopwhen); return $r; } # $self->idle_start() # Enter IDLE (RFC 2177). # Return the command tag. sub idle_start($) { my $self = shift; $self->fail("Server did not advertise IDLE (RFC 2177) capability.") unless $self->_capable('IDLE'); my $tag = $self->_cmd_init('IDLE'); $self->_cmd_flush(); return $tag; } # $self->idle_stop($tag, $callback) # Stop a running IDLE (RFC 2177) command $tag. # Returns the number of untagged responses received between the DONE # the tagged response that are satisfying $callback. sub idle_stop($$$) { my ($self, $tag, $callback) = @_; my $r = 0; # done idling $self->_cmd_extend('DONE'); $self->_cmd_flush(); # run the callback again to update the return value if we received # untagged responses between the DONE and the tagged response $self->_recv($tag, sub($;$$) { $r++ if $callback->($self, @_) }, 'slurp'); return $r; } # $self->set_cache($mailbox, STATE) # Initialize or update the persistent cache, that is, associate a # known $mailbox with the last known (synced) state: # * UIDVALIDITY # * UIDNEXT: Any message the UID of which is at least UIDNEXT is # considered new and must be downloaded. (If 0 or missing, all # messages in $mailbox are considered new.) Note that while all # UIDs in the map are panic(); $mailbox = 'INBOX' if uc $mailbox eq 'INBOX'; # INBOX is case-insensitive my $cache = $self->{_PCACHE}->{$mailbox} //= {}; my %status = @_; while (my ($k, $v) = each %status) { if ($k eq 'UIDVALIDITY') { # try to detect UIDVALIDITY changes early (before starting the sync) $self->fail("UIDVALIDITY changed! ($cache->{UIDVALIDITY} != $v) ". "Need to invalidate the UID cache for $mailbox.") if defined $cache->{UIDVALIDITY} and $cache->{UIDVALIDITY} != $v; } $cache->{$k} = $v; } $self->logger("Update last clean state for $mailbox: ". '('.join(' ', map {"$_ $cache->{$_}"} grep {defined $cache->{$_}} keys %$cache).')') if $self->{debug}; } # $self->uidvalidity([$mailbox]) # Return the UIDVALIDITY for $mailbox, or hash mapping each mailbox to # its UIDVALIDITY if $mailbox is omitted. sub uidvalidity($;$) { my $self = shift; my $mailbox = shift; if (defined $mailbox) { $mailbox = 'INBOX' if uc $mailbox eq 'INBOX'; # INBOX is case-insensitive my $cache = $self->{_CACHE}->{$mailbox} // return; return $cache->{UIDVALIDITY}; } else { my %uidvalidity; while (my ($mbx,$cache) = each %{$self->{_CACHE}}) { $uidvalidity{$mbx} = $cache->{UIDVALIDITY} if ($cache->{UIDVALIDITY} // 0) > 0; } return %uidvalidity; } } # $self->get_cache(@attributes) # Return the persistent cache for the mailbox currently selected. If # some @attributes are given, return the list of values corresponding # to these attributes. # /!\ Should only be called right after pull_updates! # Croak if there are unprocessed VANISHED responses or FLAG updates. sub get_cache($@) { my $self = shift; $self->fail("Invalid method 'get_cache' in state $self->{_STATE}") unless $self->{_STATE} eq 'SELECTED'; my $mailbox = $self->{_SELECTED} // $self->panic(); $self->panic("Pending VANISHED responses!") if @{$self->{_VANISHED}}; $self->panic("Pending FLAG updates!") if %{$self->{_MODIFIED}}; my $cache = $self->{_PCACHE}->{$mailbox}; return @_ ? @$cache{@_} : %$cache; } # $self->is_dirty($mailbox) # Return true if there are pending updates for $mailbox, i.e., if its # internal cache's HIGHESTMODSEQ or UIDNEXT values differ from its # persistent cache's values. sub is_dirty($$) { my ($self, $mailbox) = @_; return 1 if $self->{_NEW}; $self->_updated_cache($mailbox, qw/HIGHESTMODSEQ UIDNEXT/); } # $self->has_new_mails($mailbox) # Return true if there are new messages in $mailbox, i.e., if its # internal cache's UIDNEXT value differs from its persistent cache's. sub has_new_mails($$) { my ($self, $mailbox) = @_; return 1 if $self->{_NEW}; $self->_updated_cache($mailbox, 'UIDNEXT'); } # $self->next_dirty_mailbox(@mailboxes) # Return the name of a dirty mailbox, or undef if all mailboxes are # clean. If @mailbox is non-empty, only consider mailboxes in that # list. sub next_dirty_mailbox($@) { my $self = shift; my %mailboxes = map {$_ => 1} @_; my @dirty = grep { (!%mailboxes or $mailboxes{$_}) and $self->is_dirty($_) } keys %{$self->{_CACHE}}; if ($self->{debug}) { @dirty ? $self->logger("Dirty mailboxes: ".join(', ', @dirty)) : $self->logger("Clean state!"); } return $dirty[0]; } # $self->pull_updates([$full]) # If $full is set, FETCH FLAGS and MODSEQ for each UID up to # UIDNEXT-1. # Get pending updates (unprocessed VANISHED responses and FLAG # updates), and empty these lists from the cache. # Finally, update the HIGHESTMODSEQ from the persistent cache to the # value found in the internal cache. sub pull_updates($;$) { my $self = shift; my $full = shift // 0; my $mailbox = $self->{_SELECTED} // $self->panic(); my $pcache = $self->{_PCACHE}->{$mailbox}; $self->_send("UID FETCH 1:".($pcache->{UIDNEXT}-1)." (MODSEQ FLAGS)") if $full and ($pcache->{UIDNEXT} // 1) > 1; my %modified; while (%{$self->{_MODIFIED}}) { my @missing; while (my ($uid,$v) = each %{$self->{_MODIFIED}}) { # don't filter on the fly (during FETCH responses) because FLAG updates # can arrive while processing pull_new_messages() for instance if (defined $v->[1] and $v->[0] > 0) { # setting the MODSEQ to 0 forces a FETCH next unless $uid < ($pcache->{UIDNEXT} // 1) # out of bounds and ($full or $v->[0] > ($pcache->{HIGHESTMODSEQ} // 0)); # already seen $modified{$uid} = $full ? $v : $v->[1]; } else { push @missing, $uid; } } $self->{_MODIFIED} = {}; # non-empty @missing indicates a discouraged (but allowed) CONDSTORE server behavior, # cf. RFC 7162 sec. 3.1.3 ex. 8 and the comment in push_flag_updates() below with_set(compact_set(@missing), sub($) { $self->_send("UID FETCH $_[0] (MODSEQ FLAGS)") }) if @missing; } # do that afterwards since the UID FETCH command above can produce VANISHED responses my %vanished = map {$_ => 1} grep { $_ < ($pcache->{UIDNEXT} // 1) } @{$self->{_VANISHED}}; my @vanished = keys %vanished; $self->{_VANISHED} = []; # ignore FLAG updates on VANISHED messages delete @modified{@vanished}; # update the persistent cache for HIGHESTMODSEQ (not for UIDNEXT # since there might be new messages) $self->set_cache($mailbox, %{$self->{_CACHE}->{$mailbox}}{HIGHESTMODSEQ}); return (\@vanished, \%modified); } # $self->pull_new_messages($attrs, $callback, @ignore) # FETCH new messages since the UIDNEXT found in the persistent cache # (or 1 in no such UIDNEXT is found), and process each response on the # fly with the callback. # The list of attributes to FETCH, $attr, must contain BODY[]. # If an @ignore list is supplied, then these messages are ignored from # the UID FETCH range. # Finally, update the UIDNEXT from the persistent cache to the value # found in the internal cache. # /!\ Use pull_updates afterwards to udpate the HIGHESTMODSEQ! sub pull_new_messages($$&@) { my $self = shift; my $attrs = shift; my $callback = shift; my @ignore = sort { $a <=> $b } @_; my $mailbox = $self->{_SELECTED} // $self->panic(); my $cache = $self->{_CACHE}->{$mailbox}; my $UIDNEXT; do { my $range = ''; my $first; my $since = $self->{_PCACHE}->{$mailbox}->{UIDNEXT} || 1; foreach my $uid (@ignore) { if ($since < $uid) { $first //= $since; $range .= ',' if $range ne ''; $range .= $since; $range .= ':'.($uid-1) if $since < $uid-1; $since = $uid+1; } elsif ($since == $uid) { $since++; } } $first //= $since; $range .= ',' if $range ne ''; # 2^32-1: don't use '*' since the highest UID can be known already $range .= "$since:4294967295"; $UIDNEXT = $cache->{UIDNEXT} // $self->panic("Unknown UIDNEXT value - non-compliant server?"); $self->fetch($range, "($attrs)", sub($) { my $mail = shift; $UIDNEXT = $mail->{UID} + 1 if $UIDNEXT <= $mail->{UID}; $callback->($mail) if defined $callback; }) if $first < $UIDNEXT or $self->{_NEW}; # update the persistent cache for UIDNEXT (not for HIGHESTMODSEQ # since there might be pending updates) $self->set_cache($mailbox, UIDNEXT => $UIDNEXT); $self->{_NEW} = 0; } # loop if new messages were received in the meantime while ($self->{_NEW} or $UIDNEXT < $cache->{UIDNEXT}); } # $self->push_flag_updates($flags, @set) # Change the flags to each UID in @set to $flags. # A flag update fails for mails being updated after the HIGHESTMODSEQ # found in the persistent cache; push such messages to the MODIFIED # list. sub push_flag_updates($$@) { my $self = shift; my $flags = shift; my @set = @_; my $mailbox = $self->{_SELECTED} // $self->panic(); my $modseq = $self->{_PCACHE}->{$mailbox}->{HIGHESTMODSEQ} // $self->panic(); my %failed; with_set(compact_set(@set), sub($) { $self->_send("UID STORE $_[0] (UNCHANGEDSINCE $modseq) FLAGS.SILENT ($flags)"); if ($IMAP_text =~ /\A\Q$IMAP_cond\E \[MODIFIED ([0-9,:]+)\] $RE_TEXT_CHAR+\z/) { foreach (split /,/, $1) { if (/\A([0-9]+)\z/) { $failed{$1} = 1; } elsif (/\A([0-9]+):([0-9]+)\z/) { my ($min, $max) = $1 < $2 ? ($1,$2) : ($2,$1); $failed{$_} = 1 foreach ($min .. $max); } else { $self->panic($_); } } } }); my @ok; foreach my $uid (@set) { my $modified = $self->{_MODIFIED}; if ($failed{$uid}) { # $uid was listed in the MODIFIED response code from RFC 7162; will FETCH # again in pull_updates(); per RFC 7162 sec. 3.1.3 $modified->{$uid} might not # be defined ("nice" servers send an untagged FETCH response, cf. example 10, # but they might omit it - allowed but discouraged CONDSTORE server behavior - # cf. example 8) $modified->{$uid} //= [ 0, undef ]; } elsif (defined (my $m = $modified->{$uid})) { # received an untagged FETCH response, remove from the list of pending changes # if the flag list was up to date (either implicitely or explicitly) if (!defined $m->[1] or $m->[1] eq $flags) { delete $modified->{$uid}; push @ok, $uid; } } } unless ($self->{quiet}) { $self->log("Updated flags ($flags) for UID ".compact_set(@ok)) if @ok; $self->log("Couldn't update flags ($flags) for UID ".compact_set(keys %failed).', '. "will try again later") if %failed; } return keys %failed; } # $self->silent_store($set, $mod, @flags) # Set / Add / Remove the flags list on the UID $set, depending on the # value of $mod ('' / '+' / '-'). # /!\ There is no guaranty that message flags are successfully updated! sub silent_store($$$@) { my $self = shift; my $set = shift; my $subcmd = shift . "FLAGS.SILENT"; my $flags = join(' ', @_); with_set($set, sub($) { $self->_send("UID STORE $_[0] $subcmd ($flags)") }); } # $self->expunge($set) # Exunge the given UID $set. # /!\ There is no guaranty that messages are successfully expunged! sub expunge($$) { my $self = shift; my $set = shift; $self->fail("Server did not advertise UIDPLUS (RFC 4315) capability.") unless $self->_capable('UIDPLUS'); with_set($set, sub($) { $self->_send("UID EXPUNGE $_[0]") }); } ############################################################################# # Private methods # $self->_ssl_error($error, [...]) # Log an SSL $error and exit with return value 1. sub _ssl_error($$@) { my $self = shift; $self->fail(@_) unless defined $self->{_SSL}; $self->log('SSL ERROR: ', @_); if ($self->{debug}) { while (my $err = Net::SSLeay::ERR_get_error()) { $self->log(Net::SSLeay::ERR_error_string($err)); } } exit 1; } # RFC 3986 appendix A my $RE_IPv4 = do { my $dec = qr/[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]/; qr/$dec(?:\.$dec){3}/ }; my $RE_IPv6 = do { my $h16 = qr/[0-9A-Fa-f]{1,4}/; my $ls32 = qr/$h16:$h16|$RE_IPv4/; qr/ (?: $h16 : ){6} $ls32 | :: (?: $h16 : ){5} $ls32 | (?: $h16 )? :: (?: $h16 : ){4} $ls32 | (?: (?: $h16 : ){0,1} $h16 )? :: (?: $h16 : ){3} $ls32 | (?: (?: $h16 : ){0,2} $h16 )? :: (?: $h16 : ){2} $ls32 | (?: (?: $h16 : ){0,3} $h16 )? :: $h16 : $ls32 | (?: (?: $h16 : ){0,4} $h16 )? :: $ls32 | (?: (?: $h16 : ){0,5} $h16 )? :: $h16 | (?: (?: $h16 : ){0,6} $h16 )? :: /x }; # Parse an IPv4 or IPv6. In list context, return a pair (IP, family), # otherwise only the IP. If the argument is not an IP (for instance if # it's a hostname), then return (undef, undef) resp. undef. The input # can optionaly be enclosed in square brackets which forces its # interpretation as an IP literal: an error is raised if it is not the # case. my $RE_IPv4_anchored = qr/\A($RE_IPv4)\z/; my $RE_IPv6_anchored = qr/\A($RE_IPv6)\z/; sub _parse_hostip($) { my $v = shift // return; my $literal = $v =~ s/\A\[(.*)\]\z/$1/ ? 1 : 0; my ($ip, $af) = $v =~ $RE_IPv4_anchored ? ($1, AF_INET) : $v =~ $RE_IPv6_anchored ? ($1, AF_INET6) : (undef, undef); die "Invalid IP literal: $v\n" if $literal and !defined($ip); return wantarray ? ($ip, $af) : $ip; } # Opens a TCP socket to the given $host and $port. sub _tcp_connect($$$) { my ($self, $host, $port) = @_; my %hints = (socktype => SOCK_STREAM, protocol => IPPROTO_TCP); my ($host2, $family) = _parse_hostip($host); if (defined $family) { $hints{family} = $family; $hints{flags} |= AI_NUMERICHOST; } else { $host2 = $host; } my ($err, @res) = getaddrinfo($host2, $port, \%hints); $self->fail("getaddrinfo($host2): $err") if $err ne ''; SOCKETS: foreach my $ai (@res) { socket (my $s, $ai->{family}, $ai->{socktype}|SOCK_CLOEXEC, $ai->{protocol}) or $self->panic("socket: $!"); # timeout connect/read/write/... after 30s # XXX we need to pack the struct timeval manually: not portable! # https://stackoverflow.com/questions/8284243/how-do-i-set-so-rcvtimeo-on-a-socket-in-perl my $timeout = pack('l!l!', 30, 0); setsockopt($s, Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, $timeout) or $self->fail("setsockopt SO_RCVTIMEO: $!"); setsockopt($s, Socket::SOL_SOCKET, Socket::SO_SNDTIMEO, $timeout) or $self->fail("setsockopt SO_RCVTIMEO: $!"); until (connect($s, $ai->{addr})) { next if $! == EINTR; # try again if connect(2) was interrupted by a signal next SOCKETS; } return $s; } $self->fail("Can't connect to $host:$port"); } sub _xwrite($$$) { my $self = shift; my ($offset, $length) = (0, length $_[1]); while ($length > 0) { my $n = syswrite($_[0], $_[1], $length, $offset); $self->fail("write: $!") unless defined $n and $n > 0; $offset += $n; $length -= $n; } } sub _xread($$$) { my ($self, $fh, $length) = @_; my $offset = 0; my $buf; while ($length > 0) { my $n = sysread($fh, $buf, $length, $offset) // $self->fail("read: $!"); $self->fail("0 bytes read (got EOF)") unless $n > 0; # EOF $offset += $n; $length -= $n; } return $buf; } # $self->_proxify($proxy, $host, $port) # Initiate the given $proxy to proxy TCP connections to $host:$port. sub _proxify($$$$) { my ($self, $proxy, $host, $port) = @_; $port = getservbyname($port, 'tcp') // $self->fail("Can't getservbyname $port") unless $port =~ /\A[0-9]+\z/; $proxy =~ /\A([A-Za-z0-9]+):\/\/(\P{Control}*\@)?([^:]+|\[[^\]]+\])(:[A-Za-z0-9]+)?\z/ or $self->fail("Invalid proxy URI $proxy"); my ($proto, $userpass, $proxyhost, $proxyport) = ($1, $2, $3, $4); $userpass =~ s/\@\z// if defined $userpass; $proxyport = defined $proxyport ? $proxyport =~ s/\A://r : 1080; my $socket = $self->_tcp_connect($proxyhost, $proxyport); if ($proto eq 'socks5' or $proto eq 'socks5h') { my $resolv = $proto eq 'socks5h' ? 1 : 0; my $v = 0x05; # RFC 1928 VER protocol version my %mech = ( ANON => 0x00 ); $mech{USERPASS} = 0x02 if defined $userpass; $self->_xwrite($socket, pack('CCC*', 0x05, scalar (keys %mech), values %mech)); my ($v2, $m) = unpack('CC', $self->_xread($socket, 2)); $self->fail('SOCKSv5', 'Invalid protocol') unless $v == $v2; %mech = reverse %mech; my $mech = $mech{$m} // ''; if ($mech eq 'USERPASS') { # RFC 1929 Username/Password Authentication for SOCKS V5 my $v = 0x01; # current version of the subnegotiation my ($u, $pw) = split /:/, $userpass, 2; $self->_xwrite($socket, pack('C2', $v,length($u)).$u.pack('C',length($pw)).$pw); my ($v2, $r) = unpack('C2', $self->_xread($socket, 2)); $self->fail('SOCKSv5', 'Invalid protocol') unless $v == $v2; $self->fail('SOCKSv5', 'Authentication failed') unless $r == 0x00; } elsif ($mech ne 'ANON') { # $m == 0xFF $self->fail('SOCKSv5', 'No acceptable authentication methods'); } my ($hostip, $fam) = _parse_hostip($host); unless (defined($fam) or $resolv) { # resove the hostname $host locally my ($err, @res) = getaddrinfo($host, undef, {socktype => SOCK_RAW}); $self->fail("getaddrinfo($host): $err") if $err ne ''; my ($addr) = first { defined($_) } map { my ($err, $ipaddr) = getnameinfo($_->{addr}, NI_NUMERICHOST, NIx_NOSERV); $err eq '' ? [$ipaddr,$_->{family}] : undef } @res; $self->fail("getnameinfo") unless defined $addr; ($hostip, $fam) = @$addr; } # send a CONNECT command (CMD 0x01) my ($typ, $addr); if (defined $fam) { $typ = $fam == AF_INET ? 0x01 : $fam == AF_INET6 ? 0x04 : $self->panic(); $addr = Socket::inet_pton($fam, $hostip); } else { # let the SOCKS server do the resolution $typ = 0x03; $addr = pack('C',length($host)) . $host; } $self->_xwrite($socket, pack('C4', $v, 0x01, 0x00, $typ) . $addr . pack('n', $port)); ($v2, my $r, my $rsv, $typ) = unpack('C4', $self->_xread($socket, 4)); $self->fail('SOCKSv5', 'Invalid protocol') unless $v == $v2 and $rsv == 0x00; my $err = $r == 0x00 ? undef : $r == 0x01 ? 'general SOCKS server failure' : $r == 0x02 ? 'connection not allowed by ruleset' : $r == 0x03 ? 'network unreachable' : $r == 0x04 ? 'host unreachable' : $r == 0x05 ? 'connection refused' : $r == 0x06 ? 'TTL expired' : $r == 0x07 ? 'command not supported' : $r == 0x08 ? 'address type not supported' : $self->panic(); $self->fail('SOCKSv5', $err) if defined $err; my $len = $typ == 0x01 ? 4 : $typ == 0x03 ? unpack('C', $self->_xread($socket, 1)) : $typ == 0x04 ? 16 : $self->panic(); $self->_xread($socket, $len + 2); # consume (and ignore) the rest of the response return $socket; } else { $self->fail("Unsupported proxy protocol $proto"); } } # $self->_ssl_verify($self, $preverify_ok, $x509_ctx) # SSL verify callback function, see # https://www.openssl.org/docs/manmaster/man3/SSL_CTX_set_verify.html sub _ssl_verify($$$) { my ($self, $ok, $x509_ctx) = @_; return 0 unless $x509_ctx; # reject my $depth = Net::SSLeay::X509_STORE_CTX_get_error_depth($x509_ctx); my $cert = Net::SSLeay::X509_STORE_CTX_get_current_cert($x509_ctx) or $self->_ssl_error("Can't get current certificate"); if ($self->{debug}) { $self->log("[$depth] preverify=$ok"); $self->log(' Issuer Name: ', Net::SSLeay::X509_NAME_oneline(Net::SSLeay::X509_get_issuer_name($cert))); $self->log(' Subject Name: ', Net::SSLeay::X509_NAME_oneline(Net::SSLeay::X509_get_subject_name($cert))); } $ok = 1 unless $self->{SSL_verify} // die; # safety check, always set if ($depth == 0 and !exists $self->{_SSL_PEER_VERIFIED}) { if ($self->{debug}) { my $algo = 'sha256'; my $type = Net::SSLeay::EVP_get_digestbyname($algo) or $self->_ssl_error("Can't find MD value for name '$algo'"); $self->log('Peer certificate fingerprint: ' .$algo.'$'.unpack('H*', Net::SSLeay::X509_digest($cert, $type))); } if (defined (my $fprs = $self->{SSL_fingerprint})) { my $rv = 0; foreach my $fpr (split /\s+/, $fprs) { (my $algo, $fpr) = $fpr =~ /^([^\$]+)\$(.*)/ ? ($1, $2) : ('sha256', $fpr); my $digest = pack 'H*', ($fpr =~ tr/://rd); my $type = Net::SSLeay::EVP_get_digestbyname($algo) or $self->_ssl_error("Can't find MD value for name '$algo'"); my $pkey = Net::SSLeay::X509_get_X509_PUBKEY($cert); if (defined $pkey and Net::SSLeay::EVP_Digest($pkey, $type) eq $digest) { $self->log('Peer certificate matches pinned SPKI digest ', $algo .'$'. $fpr) if $self->{debug}; $rv = 1; last; } } unless ($rv) { $self->warn("Fingerprint doesn't match! MiTM in action?"); $ok = 0; } } $self->{_SSL_PEER_VERIFIED} = $ok; } return $ok; # 1=accept cert, 0=reject } my %SSL_proto; BEGIN { # TODO deprecated, remove in 0.6 sub _append_ssl_proto($$) { my ($k, $v) = @_; $SSL_proto{$k} = $v if defined $v; } _append_ssl_proto( "SSLv2", eval { Net::SSLeay::OP_NO_SSLv2() } ); _append_ssl_proto( "SSLv3", eval { Net::SSLeay::OP_NO_SSLv3() } ); _append_ssl_proto( "TLSv1", eval { Net::SSLeay::OP_NO_TLSv1() } ); _append_ssl_proto( "TLSv1.1", eval { Net::SSLeay::OP_NO_TLSv1_1() } ); _append_ssl_proto( "TLSv1.2", eval { Net::SSLeay::OP_NO_TLSv1_2() } ); _append_ssl_proto( "TLSv1.3", eval { Net::SSLeay::OP_NO_TLSv1_3() } ); } # see ssl/ssl_conf.c:protocol_from_string() in the OpenSSL source tree my %SSL_protocol_versions = ( "SSLv3" => eval { Net::SSLeay::SSL3_VERSION() } , "TLSv1" => eval { Net::SSLeay::TLS1_VERSION() } , "TLSv1.1" => eval { Net::SSLeay::TLS1_1_VERSION() } , "TLSv1.2" => eval { Net::SSLeay::TLS1_2_VERSION() } , "TLSv1.3" => eval { Net::SSLeay::TLS1_3_VERSION() } ); # $self->_start_ssl($socket) # Upgrade the $socket to SSL/TLS. sub _start_ssl($$) { my ($self, $socket) = @_; # need OpenSSL 1.1.0 or later for SSL_CTX_set_min_proto_version(3ssl), see # https://www.openssl.org/docs/man1.1.0/man3/SSL_CTX_set_min_proto_version.html $self->panic("SSL/TLS functions require OpenSSL 1.1.0 or later") if Net::SSLeay::OPENSSL_VERSION_NUMBER() < 0x1010000f; my $ctx = Net::SSLeay::CTX_new() or $self->panic("SSL_CTX_new(): $!"); $self->{SSL_verify} //= 1; # default is to perform certificate verification if (defined $self->{_OUTBUF} and $self->{_OUTBUF} ne '') { $self->warn("Truncating non-empty output buffer (unauthenticated response injection?)"); undef $self->{_OUTBUF}; } my $ssl_options = Net::SSLeay::OP_SINGLE_DH_USE() | Net::SSLeay::OP_SINGLE_ECDH_USE(); $ssl_options |= Net::SSLeay::OP_NO_COMPRESSION(); if (defined $self->{SSL_protocol_min} or defined $self->{SSL_protocol_max}) { my ($min, $max) = @$self{qw/SSL_protocol_min SSL_protocol_max/}; if (defined $min) { my $v = $SSL_protocol_versions{$min} // $self->panic("Unknown protocol version: $min"); $self->_ssl_error("CTX_set_min_proto_version()") unless Net::SSLeay::CTX_set_min_proto_version($ctx, $v) == 1; $self->log("Minimum SSL/TLS protocol version: ", $min) if $self->{debug}; } if (defined $max) { my $v = $SSL_protocol_versions{$max} // $self->panic("Unknown protocol version: $max"); $self->_ssl_error("CTX_set_max_proto_version()") unless Net::SSLeay::CTX_set_max_proto_version($ctx, $v) == 1; $self->log("Maximum SSL/TLS protocol version: ", $max) if $self->{debug}; } } elsif (defined (my $protos = $self->{SSL_protocols})) { # TODO deprecated, remove in 0.6 $self->warn("SSL_protocols is deprecated and will be removed in a future release! " . "Use SSL_protocol_{min,max} instead."); my ($proto_include, $proto_exclude) = (0, 0); foreach (split /\s+/, $protos) { my $neg = s/^!// ? 1 : 0; s/\.0$//; ($neg ? $proto_exclude : $proto_include) |= $SSL_proto{$_} // $self->panic("Unknown SSL protocol: $_"); } if ($proto_include != 0) { # exclude all protocols except those explictly included my $x = 0; $x |= $_ foreach values %SSL_proto; $x &= ~ $proto_include; $proto_exclude |= $x; } my @proto_exclude = grep { ($proto_exclude & $SSL_proto{$_}) != 0 } keys %SSL_proto; $self->log("Disabling SSL protocols: ".join(', ', sort @proto_exclude)) if $self->{debug}; $ssl_options |= $SSL_proto{$_} foreach @proto_exclude; } # https://www.openssl.org/docs/manmaster/man3/SSL_CTX_set_options.html # TODO 0.6: move SSL_CTX_set_options() and SSL_CTX_set_mode() before SSL_CTX_set_{min,max}_proto_version() Net::SSLeay::CTX_set_options($ctx, $ssl_options); # https://www.openssl.org/docs/manmaster/man3/SSL_CTX_set_mode.html Net::SSLeay::CTX_set_mode($ctx, Net::SSLeay::MODE_ENABLE_PARTIAL_WRITE() | Net::SSLeay::MODE_ACCEPT_MOVING_WRITE_BUFFER() | Net::SSLeay::MODE_AUTO_RETRY() | # don't fail SSL_read on renegotiation Net::SSLeay::MODE_RELEASE_BUFFERS() ); if (defined (my $str = $self->{SSL_cipherlist})) { $self->_ssl_error("SSL_CTX_set_cipher_list()") unless Net::SSLeay::CTX_set_cipher_list($ctx, $str) == 1; } if (defined (my $str = $self->{SSL_ciphersuites})) { $self->_ssl_error("SSL_CTX_set_ciphersuites()") unless Net::SSLeay::CTX_set_ciphersuites($ctx, $str) == 1; } my $vpm = Net::SSLeay::X509_VERIFY_PARAM_new() or $self->_ssl_error("X509_VERIFY_PARAM_new()"); my $purpose = Net::SSLeay::X509_PURPOSE_SSL_SERVER(); $self->_ssl_error("X509_VERIFY_PARAM_set_purpose()") unless Net::SSLeay::X509_VERIFY_PARAM_set_purpose($vpm, $purpose) == 1; $self->_ssl_error("CTX_set_purpose()") unless Net::SSLeay::CTX_set_purpose($ctx, $purpose) == 1; my $host = $self->{host} // $self->panic(); my ($hostip, $hostipfam) = _parse_hostip($host); if ($self->{SSL_verify}) { # verify certificate chain if (defined $self->{SSL_CAfile} or defined $self->{SSL_CApath}) { $self->_ssl_error("SSL_CTX_load_verify_locations()") unless Net::SSLeay::CTX_load_verify_locations($ctx, $self->{SSL_CAfile} // '', $self->{SSL_CApath} // '') == 1; } else { $self->log("Using default locations for trusted CA certificates") if $self->{debug}; $self->_ssl_error("SSL_CTX_set_default_verify_paths()") unless Net::SSLeay::CTX_set_default_verify_paths($ctx) == 1; } # verify DNS hostname or IP literal if (defined $hostipfam) { my $addr = Socket::inet_pton($hostipfam, $hostip) // $self->panic(); $self->_ssl_error("X509_VERIFY_PARAM_set1_ip()") unless Net::SSLeay::X509_VERIFY_PARAM_set1_ip($vpm, $addr) == 1; } else { $self->_ssl_error("X509_VERIFY_PARAM_set1_host()") unless Net::SSLeay::X509_VERIFY_PARAM_set1_host($vpm, $host) == 1; } } else { Net::SSLeay::CTX_set_verify_depth($ctx, 0); } Net::SSLeay::CTX_set_verify($ctx, Net::SSLeay::VERIFY_PEER(), sub($$) {$self->_ssl_verify(@_)}); $self->_ssl_error("CTX_SSL_set1_param()") unless Net::SSLeay::CTX_set1_param($ctx, $vpm) == 1; my $ssl = Net::SSLeay::new($ctx) or $self->fail("SSL_new()"); $self->fail("SSL_set_fd()") unless Net::SSLeay::set_fd($ssl, fileno($socket)) == 1; # always use 'SSL_hostname' when set, otherwise use 'host' (unless it's an IP) my $servername = $self->{SSL_hostname} // (defined $hostipfam ? "" : $host); if ($servername ne "") { $self->_ssl_error("SSL_set_tlsext_host_name($servername)") unless Net::SSLeay::set_tlsext_host_name($ssl, $servername) == 1; $self->log("Using SNI with name $servername") if $self->{debug}; } $self->_ssl_error("Can't initiate TLS/SSL handshake") unless Net::SSLeay::connect($ssl) == 1; $self->panic() unless $self->{_SSL_PEER_VERIFIED}; # sanity check $self->panic() if $self->{SSL_verify} and Net::SSLeay::get_verify_result($ssl) != Net::SSLeay::X509_V_OK(); Net::SSLeay::X509_VERIFY_PARAM_free($vpm); if ($self->{debug}) { $self->log(sprintf('SSL protocol: %s (0x%x)', , Net::SSLeay::get_version($ssl) , Net::SSLeay::version($ssl))); $self->log(sprintf('SSL cipher: %s (%d bits)' , Net::SSLeay::get_cipher($ssl) , Net::SSLeay::get_cipher_bits($ssl))); } @$self{qw/_SSL _SSL_CTX/} = ($ssl, $ctx); undef $self; # the verify callback has reference to $self, free it now } # $self->_getline([$length]) # Read a line from the handle and strip the trailing CRLF, optionally # after reading a literal of the given $length (default: 0). # In list context, return a pair ($literal, $line); otherwise only # return the $line. # /!\ Don't use this method with non-blocking IO! sub _getline($;$) { my $self = shift; my $len = shift // 0; my ($stdout, $ssl) = @$self{qw/S _SSL/}; $self->fail("Lost connection") unless $stdout->opened(); my (@lit, @line); while(1) { unless (defined $self->{_OUTBUF}) { my ($buf, $n); # nothing cached: read some more if (defined $ssl) { ($buf, $n) = Net::SSLeay::read($ssl, $BUFSIZE); } else { $n = sysread($stdout, $buf, $BUFSIZE, 0); } $self->_ssl_error("read: $!") unless defined $n; $self->_ssl_error("0 bytes read (got EOF)") unless $n > 0; # EOF $self->{_OUTRAWCOUNT} += $n; if (defined (my $i = $self->{_Z_INFLATE})) { my $r = $i->inflate($buf, $self->{_OUTBUF}); $self->panic("Inflation failed: $r ", $i->msg()) unless $r == Z_OK or $r == Z_STREAM_END; } else { $self->{_OUTBUF} = $buf; } } if ($len == 0) { # read a regular line: stop after the first \r\n if ((my $idx = 1 + index($self->{_OUTBUF}, "\n")) > 0) { # found the EOL, we're done my $lit = join '', @lit; my $line = join '', @line, substr($self->{_OUTBUF}, 0, $idx); $self->{_OUTBUF} = substr($self->{_OUTBUF}, $idx); $self->{_OUTCOUNT} += length($lit) + length($line); $line =~ s/$CRLF\z// or $self->panic($line); $self->logger('S: '.(@lit ? '[...]' : ''), $line) if $self->{debug}; return (wantarray ? (\$lit, $line) : $line); } else { push @line, $self->{_OUTBUF}; undef $self->{_OUTBUF}; } } elsif ($len > 0) { # $len bytes of literal bytes to read if ($len < length($self->{_OUTBUF})) { push @lit, substr($self->{_OUTBUF}, 0, $len, ''); $len = 0; } else { push @lit, $self->{_OUTBUF}; $len -= length($self->{_OUTBUF}); undef $self->{_OUTBUF}; } } } } # $self->_update_cache( ATTRIBUTE => VALUE, [...] ) # Update the internal cache for the currently selected mailbox with # the given attributes and values. sub _update_cache($%) { my $self = shift; $self->_update_cache_for($self->{_SELECTED}, @_); } # $self->_update_cache_for( $mailbox, ATTRIBUTE => VALUE, [...] ) # Update the internal cache for $mailbox with the given attributes and # values. sub _update_cache_for($$%) { my $self = shift; my $mailbox = shift // $self->panic(); my $cache = $self->{_CACHE}->{$mailbox} //= {}; my %status = @_; while (my ($k, $v) = each %status) { if ($k eq 'UIDVALIDITY') { # try to detect UIDVALIDITY changes early (before starting the sync) $self->fail("UIDVALIDITY changed! ($cache->{UIDVALIDITY} != $v) ". "Need to invalidate the UID cache for $mailbox.") if defined $cache->{UIDVALIDITY} and $cache->{UIDVALIDITY} != $v; $self->{_PCACHE}->{$mailbox}->{UIDVALIDITY} //= $v; } $cache->{$k} = $v; } } # $self->_updated_cache($mailbox) # Return true if there are pending updates for $mailbox, i.e., if one # of its internal cache's @attrs value differs from the persistent # cache's value. sub _updated_cache($$@) { my ($self, $mailbox, @attrs) = @_; $mailbox = 'INBOX' if uc $mailbox eq 'INBOX'; # INBOX is case-insensitive my $cache = $self->{_CACHE}->{$mailbox} // return 1; my $pcache = $self->{_PCACHE}->{$mailbox} // return 1; foreach (@attrs) { return 1 unless $pcache->{$_} and defined $cache->{$_} and $pcache->{$_} == $cache->{$_}; } return 0; } # $self->_cmd_init($command) # Generate a new tag for the given $command, push both the # concatenation to the command buffer. $command can be a scalar or a # scalar reference. # Use the _cmd_extend and/or _cmd_extend_lit methods to extend the # command, and _cmd_flush to send it to the server. sub _cmd_init($$) { my $self = shift; my $tag = sprintf '%06d', $self->{_TAG}++; my $command = (defined $self->{_INBUF} ? $CRLF : '').$tag.' '.(ref $_[0] ? ${$_[0]} : $_[0]); $self->_cmd_extend(\$command); return $tag; } # $self->_cmd_extend($args) # Append $args to the command buffer. $args can be a scalar or a # scalar reference. If $args contains some literal(s) and the server # doesn't support LITERAL+, flush the command and wait for an answer # before each literal sub _cmd_extend($$) { my $self = shift; my $args = ref $_[0] ? $_[0] : \$_[0]; if ($self->{_LITPLUS} ne '') { # server supports LITERAL+: use $args as is $self->_cmd_extend_($args); } else { # server doesn't supports LITERAL+: flush the command before # each literal my ($offset, $litlen) = (0, 0); while ( (my $idx = index($$args, "\n", $offset+$litlen)) >= 0 ) { my $line = substr($$args, $offset, $idx+1-$offset); $line =~ s/\{([0-9]+)\+\}$CRLF\z/{$1}$CRLF/ or $self->panic(); $litlen = $1; $self->_cmd_flush(\$line); my $x = $self->_getline(); $x =~ /\A\+ / or $self->panic($x); $offset = $idx+1; } my $line = substr($$args, $offset); $self->_cmd_extend_(\$line); } } # $self->_cmd_extend_lit($lit) # Append the literal $lit to the command buffer. $lit must be a # scalar reference. sub _cmd_extend_lit($$) { my ($self, $lit) = @_; my $len = length($$lit); my $d = $self->{_Z_DEFLATE}; # create a full flush point for long literals, cf. RFC 4978 section 4 my $z_flush = $len > $BUFSIZE ? 1 : 0; my $strlen = "{$len$self->{_LITPLUS}}$CRLF"; if ($self->{_LITPLUS} ne '') { $self->_cmd_extend_(\$strlen); if ($z_flush and defined $d) { $d->flush(\$self->{_INBUF}, Z_FULL_FLUSH) == Z_OK or $self->panic("Can't flush deflation stream: ", $d->msg()); } } else { # server doesn't supports LITERAL+ $self->_cmd_flush(\$strlen, ($z_flush ? Z_FULL_FLUSH : ())); my $x = $self->_getline(); $x =~ /\A\+ / or $self->panic($x); } $self->_cmd_extend_($lit); if ($z_flush and defined $d) { $d->flush(\$self->{_INBUF}, Z_FULL_FLUSH) == Z_OK or $self->panic("Can't flush deflation stream: ", $d->msg()); } } # $self->_cmd_flush([$crlf], [$z_flush]) # Append $crlf (default: $CRLF) to the command buffer, flush the # deflation stream by creating a flush point of type $z_flush # (default: Z_SYNC_FLUSH) if there is a compression layer, and finally # send the command to the server. sub _cmd_flush($;$$) { my $self = shift; $self->_cmd_extend_( $_[0] // \$CRLF ); my $z_flush = $_[1] // Z_SYNC_FLUSH; # the flush point type to use my ($stdin, $ssl) = @$self{qw/S _SSL/}; if ($self->{debug}) { # remove $CRLF and literals my ($offset, $litlen) = (0, $self->{_INBUFDBGLEN} // 0); while ( (my $idx = index($self->{_INBUFDBG}, "\n", $offset+$litlen)) >= 0) { my $line = substr($self->{_INBUFDBG}, $offset+$litlen, $idx+1-$offset-$litlen); $line =~ s/$CRLF\z// or $self->panic(); $self->logger('C: ', ($litlen > 0) ? '[...]' : '', $line); $litlen = $line =~ /\{([0-9]+)(\+)?\}\z/ ? $1 : 0; $offset = $idx+1; } $self->panic() if $offset+$litlen < length($self->{_INBUFDBG}); undef $self->{_INBUFDBG}; $self->{_INBUFDBGLEN} = $litlen; } if (defined (my $d = $self->{_Z_DEFLATE})) { $d->flush(\$self->{_INBUF}, $z_flush) == Z_OK or $self->panic("Can't flush deflation stream: ", $d->msg()); } my ($offset, $length) = (0, length($self->{_INBUF})); while ($length > 0) { my $written = defined $ssl ? Net::SSLeay::write_partial($ssl, $offset, $length, $self->{_INBUF}) : syswrite($stdin, $self->{_INBUF}, $length, $offset); $self->_ssl_error("write: $!") unless defined $written and $written > 0; $offset += $written; $length -= $written; $self->{_INRAWCOUNT} += $written; } undef $self->{_INBUF}; } # $self->_cmd_extend_($args) # Append the scalar reference $args to the command buffer. Usually # one should use the higher-level method _cmd_extend as it takes care # of literals if the server doesn't support LITERAL+. sub _cmd_extend_($$) { my ($self, $args) = @_; $self->{_INCOUNT} += length($$args); # count IMAP traffic $self->{_INBUFDBG} .= $$args if $self->{debug}; if (defined (my $d = $self->{_Z_DEFLATE})) { $d->deflate($args, \$self->{_INBUF}) == Z_OK or $self->panic("Deflation failed: ", $d->msg()); } else { $self->{_INBUF} .= $$args; } } # $self->_send($command, [$callback]) # Send the given $command to the server, then wait for the response. # (The status condition and response text are respectively placed in # $IMAP_cond and $IMAP_text.) Each untagged response received in the # meantime is read, parsed and processed. The optional $callback, if # given, is executed with all untagged responses associated with the # command. # In void context, croak unless the server answers with a tagged 'OK' # response. Otherwise, return the condition status ('OK'/'NO'/'BAD'). sub _send($$;&) { my $self = shift; my $command = \$_[0]; my $callback = $_[1]; my $tag = $self->_cmd_init($command); $self->_cmd_flush(); my $cmd = $$command =~ /\AUID ($RE_ATOM_CHAR+) / ? $1 : $$command =~ /\A($RE_ATOM_CHAR+) / ? $1 : $$command; if (!defined $callback) { $self->_recv($tag, undef, $cmd); } else { my $set = $$command =~ /\AUID (?:FETCH|STORE) ([0-9:,*]+)/ ? $1 : $$command =~ /\AUID SEARCH / ? $tag # for RFC 4466's tag-string : undef; $self->_recv($tag, $callback, $cmd, $set); } } # $self->_recv($tag, [$callback, $command, $set]) # Wait for a tagged response with the given $tag. The $callback, if # provided, is used to process each untagged response. $command and # $set can further limit the set of responses to apply the callback # to. sub _recv($$;&$$) { my ($self, $tag, $callback, $cmd, $set) = @_; my $r; # wait for the answer while (1) { my $x = $self->_getline(); if ($x =~ s/\A\Q$tag\E (OK|NO|BAD) //) { $IMAP_cond = $1; $IMAP_text = $1.' '.$x; $self->_resp_text($x); $self->fail($IMAP_text) unless defined wantarray or $IMAP_cond eq 'OK'; $r = $1; last; } else { $self->_resp($x, $callback, $cmd, $set); } } if (defined $self->{_SELECTED}) { my $mailbox = $self->{_SELECTED}; my $cache = $self->{_CACHE}->{$mailbox}; # can't keep track of the modification sequences $self->fail("Mailbox $mailbox doesn't support MODSEQ.") if $cache->{NOMODSEQ} and $self->_enabled('QRESYNC'); $self->fail("Mailbox $mailbox does not support persistent UIDs.") if defined $cache->{UIDNOTSTICKY}; } return $r; } # $self->_capable($capability, [...]) # Return true if each $capability is listed in the server's CAPABILITY # list. sub _capable($@) { my $self = shift; return 0 unless defined $self->{_CAPABILITIES}; foreach my $cap (@_) { return 0 unless grep {uc $cap eq uc $_} @{$self->{_CAPABILITIES}}; } return 1; } # $self->_capable($extension) # Return true if $extension has been enabled by the server, i.e., the # server sent an untagged ENABLED response including it. sub _enabled($$) { my $self = shift; my $ext = uc shift; grep {$ext eq uc $_} @{$self->{_ENABLED} // []}; } # $self->_open_mailbox($mailbox) # Initialize the internal and persistent caches for $mailbox, and mark # it as selected. sub _open_mailbox($$) { my $self = shift; my $mailbox = shift; # it is safe to wipe cached VANISHED responses or FLAG updates, # because interesting stuff must have made the mailbox dirty so # we'll get back to it $self->{_VANISHED} = []; $self->{_MODIFIED} = {}; $self->{_NEW} = 0; $self->{_SELECTED} = $mailbox; $self->{_CACHE}->{$mailbox} //= {}; # always reset EXISTS to keep track of new mails delete $self->{_CACHE}->{$mailbox}->{EXISTS}; } # $self->_select_or_examine($command, $mailbox, [$seqs, $UIDs]) # Issue a SELECT or EXAMINE command for the $mailbox. Upon success, # change the state to SELECTED, otherwise go back to AUTH. # The optional $seqs and $UIDs are used as Message Sequence Match # Data for the QRESYNC parameter to the $command. sub _select_or_examine($$$;$$) { my $self = shift; my $command = shift; my $mailbox = shift; my ($seqs, $uids) = @_; $mailbox = uc $mailbox eq 'INBOX' ? 'INBOX' : $mailbox; # INBOX is case-insensitive my $pcache = $self->{_PCACHE}->{$mailbox} //= {}; my $cache = $self->{_CACHE}->{$mailbox} //= {}; $cache->{UIDVALIDITY} = $pcache->{UIDVALIDITY} if defined $pcache->{UIDVALIDITY}; $command .= ' '.quote($mailbox); if ($self->_enabled('QRESYNC') and ($pcache->{HIGHESTMODSEQ} // 0) > 0 and ($pcache->{UIDNEXT} // 1) > 1) { $command .= " (QRESYNC ($pcache->{UIDVALIDITY} $pcache->{HIGHESTMODSEQ} " ."1:".($pcache->{UIDNEXT}-1); $command .= " ($seqs $uids)" if defined $seqs and defined $uids; $command .= "))"; } if ($self->{_STATE} eq 'SELECTED' and ($self->_capable('CONDSTORE') or $self->_capable('QRESYNC'))) { # A mailbox is currently selected and the server advertises # 'CONDSTORE' or 'QRESYNC' (RFC 7162). Delay the mailbox # selection until the [CLOSED] response code has been received: # all responses before the [CLOSED] response code refer to the # previous mailbox ($self->{_SELECTED}), while all subsequent # responses refer to the new mailbox $self->{_SELECTED_DELAYED}. $self->{_SELECTED_DELAYED} = $mailbox; } else { $self->_open_mailbox($mailbox); } $self->{_STATE} = 'AUTH'; $self->_send($command); $self->{_STATE} = 'SELECTED'; } sub _kibi($) { my $n = shift; if ($n < 1024) { $n; } elsif ($n < 1048576) { sprintf '%.2fK', $n / 1024.; } elsif ($n < 1073741824) { sprintf '%.2fM', $n / 1048576.; } else { sprintf '%.2fG', $n / 1073741824.; } } ############################################################################# # Parsing methods # # Parse an RFC 3501 (+extensions) resp-text, and update the cache when needed. sub _resp_text($$) { my $self = shift; local $_ = shift; if (/\A\[ALERT\] $RE_TEXT_CHAR+\z/) { $self->log($_); } elsif (/\A\[BADCHARSET .*\] $RE_TEXT_CHAR+\z/) { $self->fail($_); } elsif (/\A\[CAPABILITY((?: $RE_ATOM_CHAR+)+)\] $RE_TEXT_CHAR+\z/) { $self->{_CAPABILITIES} = [ split / /, ($1 =~ s/^ //r) ]; $self->{_LITPLUS} = (grep { uc $_ eq 'LITERAL+' } @{$self->{_CAPABILITIES}}) ? '+' : ''; } elsif (/\A\[PERMANENTFLAGS \(((?:(?:\\?$RE_ATOM_CHAR+|\\\*)(?: (?:\\?$RE_ATOM_CHAR+|\\\*))*))\)\] $RE_TEXT_CHAR+\z/) { $self->_update_cache( PERMANENTFLAGS => [ split / /, $1 ] ); } elsif (/\A\[(READ-ONLY|READ-WRITE)\] $RE_TEXT_CHAR+\z/) { $self->_update_cache($1 => 1); } elsif (/\A\[(UIDNEXT|UIDVALIDITY|UNSEEN) ([0-9]+)\] $RE_TEXT_CHAR+\z/) { $self->_update_cache($1 => $2); } elsif (/\A\[HIGHESTMODSEQ ([0-9]+)\] $RE_TEXT_CHAR+\z/) { # RFC 4551/7162 CONDSTORE/QRESYNC $self->_update_cache(HIGHESTMODSEQ => $1); } elsif (/\A\[NOMODSEQ\] $RE_TEXT_CHAR+\z/) { # RFC 4551/7162 CONDSTORE/QRESYNC $self->_update_cache(NOMODSEQ => 1); } elsif (/\A\[CLOSED\] $RE_TEXT_CHAR+\z/) { # RFC 7162 CONDSTORE/QRESYNC # Update the selected mailbox: previous responses refer to the # previous mailbox ($self->{_SELECTED}), while all subsequent # responses refer to the new mailbox $self->{_SELECTED_DELAYED}. my $mailbox = delete $self->{_SELECTED_DELAYED} // $self->panic(); $self->_open_mailbox($mailbox); } elsif (/\A\[(?:NOTIFICATIONOVERFLOW|BADEVENT .*)\] $RE_TEXT_CHAR+\z/) { # RFC 5465 NOTIFY $self->fail($_); } elsif (/\A\[UIDNOTSTICKY\] $RE_TEXT_CHAR+\z/) { # RFC 4315 UIDPLUS $self->_update_cache(UIDNOTSTICKY => 1); } } # Parse and consume an RFC 3501 nstring (string / "NIL"). sub _nstring($$) { my ($self, $stream) = @_; return $$stream =~ s/\ANIL// ? undef : $self->_string($stream); } # Parse and consume an RFC 3501 astring (1*ASTRING-CHAR / string). sub _astring($$) { my ($self, $stream) = @_; return $$stream =~ s/\A$RE_ASTRING_CHAR+//p ? ${^MATCH} : $self->_string($stream); } # Parse and consume an RFC 3501 list-mailbox (1*list-char / string). sub _list_mailbox($$) { my ($self, $stream) = @_; return $$stream =~ s/\A$RE_LIST_CHAR+//p ? ${^MATCH} : $self->_string($stream); } # Parse and consume an RFC 3501 string (quoted / literal). sub _string($$) { my ($self, $stream) = @_; if ($$stream =~ s/\A"((?:\\[\x22\x5C]|[\x01-\x09\x0B\x0C\x0E-\x21\x23-\x5B\x5D-\x7F])*)"//) { # quoted my $str = $1; $str =~ s/\\([\x22\x5C])/$1/g; return $str; } elsif ($$stream =~ s/\A\{([0-9]+)\}\z//) { # literal (my $lit, $$stream) = $self->_getline($1); return $$lit; } else { $self->panic($$stream); } } # Parse and consume an RFC 3501 "(" 1*address ")" / "NIL". sub _addresses($$) { my ($self, $stream) = @_; return undef if $$stream =~ s/\ANIL//; my @addresses; $$stream =~ s/\A\(// or $self->panic($$stream); while ($$stream =~ s/\A ?\(//) { my @addr; push @addr, $self->_nstring($stream); # addr-name $$stream =~ s/\A // or $self->panic($$stream); push @addr, $self->_nstring($stream); # addr-adl $$stream =~ s/\A // or $self->panic($$stream); push @addr, $self->_nstring($stream); # addr-mailbox $$stream =~ s/\A // or $self->panic($$stream); push @addr, $self->_nstring($stream); # addr-host $$stream =~ s/\A\)// or $self->panic($$stream); push @addresses, \@addr; } $$stream =~ s/\A\)// or $self->panic($$stream); return \@addresses; } # Parse and consume an RFC 3501 envelope sub _envelope($$) { my ($self, $stream) = @_; $$stream =~ s/\A\(// or $self->panic($$stream); my @envelope; push @envelope, $self->_nstring($stream); # env-date $$stream =~ s/\A // or $self->panic($$stream); push @envelope, $self->_nstring($stream); # env-subject $$stream =~ s/\A // or $self->panic($$stream); push @envelope, $self->_addresses($stream); # env-from $$stream =~ s/\A // or $self->panic($$stream); push @envelope, $self->_addresses($stream); # env-sender $$stream =~ s/\A // or $self->panic($$stream); push @envelope, $self->_addresses($stream); # env-reply-to $$stream =~ s/\A // or $self->panic($$stream); push @envelope, $self->_addresses($stream); # env-to $$stream =~ s/\A // or $self->panic($$stream); push @envelope, $self->_addresses($stream); # env-cc $$stream =~ s/\A // or $self->panic($$stream); push @envelope, $self->_addresses($stream); # env-bcc $$stream =~ s/\A // or $self->panic($$stream); push @envelope, $self->_nstring($stream); # env-in-reply-to $$stream =~ s/\A // or $self->panic($$stream); push @envelope, $self->_nstring($stream); # env-message-id $$stream =~ s/\A\)// or $self->panic($$stream); return \@envelope; } # Parse and consume an RFC 4466 tagged-ext-comp plus a trailing parenthesis sub _tagged_ext_comp($$$) { my ($self, $stream, $ret) = @_; my $v = $$stream =~ s/\A\(// ? $self->_tagged_ext_comp(\$_, []) : $self->_astring(\$_); push @$ret, $v; if ($$stream =~ s/\A\)//) { return $ret; } elsif ($$stream =~ s/\A //) { $self->_tagged_ext_comp(\$_, $ret) } else { $self->panic($$stream); } } # $self->_resp($buf, [$callback, $cmd, $set] ) # Parse an untagged response line or a continuation request line. # (The trailing CRLF must be removed.) The internal cache is # automatically updated when needed. # If a command and callback are given, the callback is be executed # for each (parsed) responses associated with the command. sub _resp($$;&$$) { my $self = shift; local $_ = shift; my $callback = shift; my $cmd = shift; my $set = shift; my $cache = $self->{_CACHE}->{$self->{_SELECTED}} if defined $self->{_SELECTED}; if (s/\A\* //) { if (s/\ABYE //) { undef $self; } elsif (s/\A(?:OK|NO|BAD) //) { $self->_resp_text($_); $callback->($self->{_SELECTED}) if defined $self->{_SELECTED} and defined $callback and $cmd eq 'slurp'; } elsif (/\ACAPABILITY((?: $RE_ATOM_CHAR+)+)\z/) { $self->{_CAPABILITIES} = [ split / /, ($1 =~ s/^ //r) ]; } elsif (/\AFLAGS \((\\?$RE_ATOM_CHAR+(?: \\?$RE_ATOM_CHAR+)*)?\)\z/) { $cache->{FLAGS} = [ split / /, $1 ]; } elsif (/\A([0-9]+) RECENT\z/) { $cache->{RECENT} = $1; } elsif (/\A([0-9]+) EXISTS\z/) { # /!\ $cache->{EXISTS} MUST NOT be defined on SELECT if (defined $cache->{EXISTS}) { $self->panic("Unexpected EXISTS shrink $1 < $cache->{EXISTS}!") if $1 < $cache->{EXISTS}; $self->{_NEW} += $1 - $cache->{EXISTS} if $1 > $cache->{EXISTS}; # new mails } $cache->{EXISTS} = $1; $callback->($self->{_SELECTED} // $self->panic(), EXISTS => $1) if defined $callback and $cmd eq 'slurp'; } elsif (/\A([0-9]+) EXPUNGE\z/) { $self->panic() unless defined $cache->{EXISTS}; # sanity check # /!\ No bookkeeping since there is no internal cache mapping sequence numbers to UIDs if ($self->_enabled('QRESYNC')) { $self->panic("$1 <= $cache->{EXISTS}") if $1 <= $cache->{EXISTS}; # sanity check $self->fail("RFC 7162 violation! Got an EXPUNGE response with QRESYNC enabled."); } # the new message was expunged before it was synced $self->{_NEW} = 0 if $self->{_NEW} == 1 and $cache->{EXISTS} == $1; $cache->{EXISTS}--; # explicit EXISTS responses are optional } elsif (/\ASEARCH((?: [0-9]+)*)\z/) { $callback->(split(/ /, ($1 =~ s/^ //r))) if defined $callback and $cmd eq 'SEARCH'; } elsif (s/\AESEARCH( |\z)/$1/) { my $tag = $1 if s/\A \(TAG \"($RE_ASTRING_CHAR+)\"\)//; my $uid = s/\A UID// ? "UID" : undef; my @ret; while ($_ ne '') { # RFC 4466 "tagged-ext-label" is a valid RFC 3501 "atom" s/\A ($RE_ATOM_CHAR+) // or $self->panic(); my $label = uc($1); my $value; if (s/\A([0-9,:]+)//) { # RFC 4466 tagged-ext-simple $value = $1; } elsif (s/\A\(//) { # RFC 4466 "(" [tagged-ext-comp] ")" $value = s/\A\)// ? [] : $self->_tagged_ext_comp(\$_, []); } else { $self->panic(); } # don't use a hash since some extensions might give more # than one response for a same key push @ret, $label => $value; } $callback->($uid, @ret) if defined $callback and $cmd eq 'SEARCH' and defined $set and $set eq $tag; } elsif (s/\ALIST \((\\?$RE_ATOM_CHAR+(?: \\?$RE_ATOM_CHAR+)*)?\) ("(?:\\[\x22\x5C]|[\x01-\x09\x0B\x0C\x0E-\x21\x23-\x5B\x5D-\x7F])"|NIL) //) { my ($delim, $attrs) = ($2, $1); my @attrs = defined $attrs ? split(/ /, $attrs) : (); my $mailbox = $self->_list_mailbox(\$_); $self->panic($_) unless $_ eq ''; $mailbox = 'INBOX' if uc $mailbox eq 'INBOX'; # INBOX is case-insensitive undef $delim if uc $delim eq 'NIL'; $self->panic($_) if defined $delim and $delim !~ s/\A"\\?(.)"\z/$1/; $self->_update_cache_for($mailbox, DELIMITER => $delim); $self->_update_cache_for($mailbox, LIST_ATTRIBUTES => \@attrs); $callback->($mailbox, $delim, @attrs) if defined $callback and $cmd eq 'LIST'; } elsif (s/\ASTATUS //) { my $mailbox = $self->_astring(\$_); /\A \((\\?$RE_ATOM_CHAR+ [0-9]+(?: \\?$RE_ATOM_CHAR+ [0-9]+)*)?\)\z/ or $self->panic($_); my %status = split / /, $1; $mailbox = 'INBOX' if uc $mailbox eq 'INBOX'; # INBOX is case-insensitive $self->panic("RFC 5465 violation! Missing HIGHESTMODSEQ data item in STATUS response") if $self->_enabled('QRESYNC') and !defined $status{HIGHESTMODSEQ} and defined $cmd and ($cmd eq 'NOTIFY' or $cmd eq 'slurp'); $self->_update_cache_for($mailbox, %status); if (defined $callback) { if ($cmd eq 'STATUS') { $callback->($mailbox, %status); } elsif ($cmd eq 'slurp') { $callback->($mailbox); } } } elsif (s/\A([0-9]+) FETCH \(//) { $cache->{EXISTS} = $1 if $1 > $cache->{EXISTS}; my ($seq, $first) = ($1, 1); my %mail; while ($_ ne ')') { unless (defined $first) { s/\A // or $self->panic($_); } if (s/\AUID ([0-9]+)//) { # always present, cf RFC 3501 section 6.4.8 $mail{UID} = $1; # the actual UIDNEXT is *at least* that $cache->{UIDNEXT} = $1+1 if !defined $cache->{UIDNEXT} or $cache->{UIDNEXT} <= $1; } if (s/\AMODSEQ \(([0-9]+)\)//) { # RFC 4551/7162 CONDSTORE/QRESYNC # always present in unsolicited FETCH responses if QRESYNC has been enabled $mail{MODSEQ} = $1; $cache->{HIGHESTMODSEQ} = $1 if !defined $cache->{HIGHESTMODSEQ} or $cache->{HIGHESTMODSEQ} < $1; } elsif (s/\AENVELOPE //) { $mail{ENVELOPE} = $self->_envelope(\$_); } elsif (s/\AINTERNALDATE "([^"]+)"//) { $mail{INTERNALDATE} = $1; } elsif (s/\A(?:RFC822|BODY\[\]) //) { $mail{RFC822} = \$self->_nstring(\$_); } elsif (s/\AFLAGS \((\\?$RE_ATOM_CHAR+(?: \\?$RE_ATOM_CHAR+)*)?\)//) { $mail{FLAGS} = defined $1 ? [ split / /, $1 ] : []; } undef $first; } $self->panic() unless defined $mail{MODSEQ} or !$self->_enabled('QRESYNC'); # sanity check my $uid = $mail{UID}; if (!exists $mail{RFC822} and !exists $mail{ENVELOPE} and # ignore new mails defined $uid and # /!\ ignore unsolicited FETCH responses without UID, cf RFC 7162 section 3.2.4 (!exists $self->{_MODIFIED}->{$uid} or $self->{_MODIFIED}->{$uid}->[0] < $mail{MODSEQ} or ($self->{_MODIFIED}->{$uid}->[0] == $mail{MODSEQ} and !defined $self->{_MODIFIED}->{$uid}->[1]))) { my $flags = join ' ', sort(grep {lc $_ ne '\recent'} @{$mail{FLAGS}}) if defined $mail{FLAGS}; $self->{_MODIFIED}->{$uid} = [ $mail{MODSEQ}, $flags ]; } if (defined $callback) { if ($cmd eq 'FETCH' or $cmd eq 'STORE') { $callback->(\%mail) if defined $uid and in_set($uid, $set); } elsif ($cmd eq 'slurp') { $callback->($self->{_SELECTED} // $self->panic(), FETCH => $seq) } } } elsif (/\AENABLED((?: $RE_ATOM_CHAR+)+)\z/) { # RFC 5161 ENABLE $self->{_ENABLED} //= []; push @{$self->{_ENABLED}}, split(/ /, ($1 =~ s/^ //r)); } elsif (/\AVANISHED( \(EARLIER\))? ([0-9,:]+)\z/) { # RFC 7162 QRESYNC my $earlier = defined $1 ? 1 : 0; my $set = $2; my $mailbox = $self->{_SELECTED} // $self->panic(); my $pcache = $self->{_PCACHE}->{$mailbox}; foreach (split /,/, $set) { if (/\A([0-9]+)\z/) { $cache->{EXISTS}-- unless $earlier; # explicit EXISTS responses are optional $cache->{UIDNEXT} = $1+1 if $cache->{UIDNEXT} <= $1; # the actual UIDNEXT is *at least* that push @{$self->{_VANISHED}}, $1; } elsif (/\A([0-9]+):([0-9]+)\z/) { my ($min, $max) = $1 < $2 ? ($1,$2) : ($2,$1); $cache->{EXISTS} -= $max-$min+1 unless $earlier; # explicit EXISTS responses are optional $cache->{UIDNEXT} = $max+1 if $cache->{UIDNEXT} <= $max; # the actual UIDNEXT is *at least* that push @{$self->{_VANISHED}}, ($min .. $max); } } $callback->($self->{_SELECTED} // $self->panic()) if defined $callback and $cmd eq 'slurp'; } } elsif (s/\A\+// and ($_ eq '' or s/\A //)) { # Microsoft Exchange Server 2010 violates RFC 3501 by skipping the trailing ' ' for empty resp-text if (defined $callback and $cmd eq 'AUTHENTICATE') { my $x = $callback->($_); $self->_cmd_extend(\$x); $self->_cmd_flush(); } } else { $self->panic("Unexpected response: ", $_); } } ############################################################################# return 1; interimap-0.5.8/pandoc2man.jq 0000775 0000000 0000000 00000001050 15003201720 0016057 0 ustar 00root root 0000000 0000000 #!/usr/bin/jq -f def fixheaders: if .t == "Header" then .c[2][] |= (if .t == "Str" then .c |= ascii_upcase else . end) else . end; def fixlinks: if type == "object" then if .t == "Link" then if .c[2][0][0:7] == "mailto:" then . else .c[1][] end else map_values(fixlinks) end else if type == "array" then map(fixlinks) else . end end; { "pandoc-api-version" , meta , blocks: .blocks | map(fixheaders | fixlinks) } interimap-0.5.8/pullimap 0000775 0000000 0000000 00000033164 15003201720 0015262 0 ustar 00root root 0000000 0000000 #!/usr/bin/perl -T #---------------------------------------------------------------------- # Pull mails from an IMAP mailbox and deliver them to an SMTP session # Copyright © 2016-2022 Guilhem Moulin # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . #---------------------------------------------------------------------- use v5.20.2; use strict; use warnings; our $VERSION = '0.5.8'; my $NAME = 'pullimap'; use Errno 'EINTR'; use Fcntl qw/O_CREAT O_RDWR O_DSYNC F_SETLK F_WRLCK SEEK_SET F_GETFD F_SETFD FD_CLOEXEC/; use Getopt::Long qw/:config posix_default no_ignore_case gnu_getopt auto_version/; use List::Util 'first'; use Socket qw/PF_INET PF_INET6 SOCK_STREAM IPPROTO_TCP/; use lib "./lib"; use Net::IMAP::InterIMAP 0.5.8 qw/xdg_basedir read_config compact_set/; # Clean up PATH $ENV{PATH} = join ':', qw{/usr/bin /bin}; delete @ENV{qw/IFS CDPATH ENV BASH_ENV/}; my %CONFIG; sub usage(;$) { my $rv = shift // 0; if ($rv) { print STDERR "Usage: $NAME [OPTIONS] SECTION\n" ."Try '$NAME --help' or consult the manpage for more information.\n"; } else { print STDERR "Usage: $NAME [OPTIONS] SECTION\n" ."Consult the manpage for more information.\n"; } exit $rv; } usage(1) unless GetOptions(\%CONFIG, qw/config=s quiet|q debug+ help|h idle:i no-delivery/); usage(0) if $CONFIG{help}; usage(1) unless $#ARGV == 0 and $ARGV[0] ne '_'; ####################################################################### # Read and validate configuration # my $CONF = do { my $conffile = delete($CONFIG{config}) // "config"; $conffile = xdg_basedir( XDG_CONFIG_HOME => ".config", $NAME, $conffile ); read_config( $conffile , [$ARGV[0]] , statefile => qr/\A(\P{Control}+)\z/ , mailbox => qr/\A([\x01-\x7F]+)\z/ , 'deliver-method' => qr/\A([ls]mtp:\[.*\]:\d+)\z/ , 'deliver-ehlo' => qr/\A(\P{Control}+)\z/ , 'deliver-rcpt' => qr/\A(\P{Control}+)\z/ , 'purge-after' => qr/\A(\d*)\z/ )->{$ARGV[0]}; }; my ($MAILBOX, $STATE); do { $MAILBOX = $CONF->{mailbox} // 'INBOX'; my $statefile = $CONF->{statefile} // $ARGV[0]; die "Missing option statefile" unless defined $statefile; $statefile = xdg_basedir( XDG_DATA_HOME => ".local/share", $NAME, $statefile ); my $mode = O_RDWR | O_DSYNC; # don't auto-create in long-lived mode $mode |= O_CREAT unless defined $CONFIG{idle}; sysopen($STATE, $statefile, $mode, 0600) or die "Can't open $statefile: $!"; # XXX we need to pack the struct flock manually: not portable! my $struct_flock = pack('s!s!l!l!i!', F_WRLCK, SEEK_SET, 0, 0, 0); fcntl($STATE, F_SETLK, $struct_flock) or die "Can't lock $statefile: $!"; my $flags = fcntl($STATE, F_GETFD, 0) or die "fcntl F_GETFD: $!"; fcntl($STATE, F_SETFD, $flags | FD_CLOEXEC) or die "fcntl F_SETFD: $!"; # We have no version number in the statefile, but if we ever need a # migration, we'll add a 1-byte header for the version number, and # assume version 1.0 if the size of the file is a multiple of 4 # bytes. (We can also use the fact that bytes 5 to 8 are never all 0.) }; ####################################################################### # Read a UID (32-bits integer) from the statefile, or undef if we're at # the end of the statefile sub readUID() { my $n = sysread($STATE, my $buf, 4) // die "read: $!"; return if $n == 0; # EOF # file length is a multiple of 4 bytes, and we always read 4 bytes at a time die "Corrupted state file!" if $n != 4; unpack('N', $buf); } # Write a UID (32-bits integer) to the statefile sub writeUID($) { my $uid = pack('N', shift); my $offset = 0; for ( my $offset = 0 ; $offset < 4 ; $offset += syswrite($STATE, $uid, 4-$offset, $offset) // die "write: $!" ) {} # no need to sync (or flush) since $STATE is opened with O_DSYNC } ####################################################################### # SMTP/LMTP part # my ($SMTP, $SMTP_PIPELINING); sub sendmail($$) { my ($from, $rfc822) = @_; unless (defined $SMTP) { # TODO we need to be able to reconnect when the server closes # the connection due to a timeout (RFC 5321 section 4.5.3.2) my ($fam, $addr) = (PF_INET, $CONF->{'deliver-method'} // 'smtp:[127.0.0.1]:25'); $addr =~ s/^([ls]mtp):// or die; my $ehlo = $1 eq 'lmtp' ? 'LHLO' : $1 eq 'smtp' ? 'EHLO' : die; $ehlo .= ' '. ($CONF->{'deliver-ehlo'} // 'localhost.localdomain'); my $port = $addr =~ s/:(\d+)$// ? $1 : die; $addr =~ s/^\[(.*)\]$/$1/ or die; $fam = PF_INET6 if $addr =~ /:/; $addr = Socket::inet_pton($fam, $addr) // die "Invalid address $addr\n"; my $sockaddr = $fam == PF_INET ? Socket::pack_sockaddr_in($port, $addr) : $fam == PF_INET6 ? Socket::pack_sockaddr_in6($port, $addr) : die; socket($SMTP, $fam, SOCK_STREAM, IPPROTO_TCP) or die "socket: $!"; until (connect($SMTP, $sockaddr)) { next if $! == EINTR; # try again if connect(2) was interrupted by a signal die "connect: $!"; } binmode($SMTP) // die "binmode: $!"; smtp_resp('220'); my @r = smtp_send($ehlo => '250'); $SMTP_PIPELINING = grep {$_ eq 'PIPELINING'} @r; # SMTP pipelining (RFC 2920) } my $rcpt = $CONF->{'deliver-rcpt'} // getpwuid($>) // die; # return codes are from RFC 5321 section 4.3.2 smtp_send( "MAIL FROM:<$from>" => '250' , "RCPT TO:<$rcpt>" => '250' , "DATA" => '354' ); print STDERR "C: [...]\n" if $CONFIG{debug}; if (!defined $$rfc822 or $$rfc822 eq "") { # RFC 5321 section 4.1.1.4: if there was no mail data, the first # "\r\n" ends the DATA command itself $SMTP->printflush("\r\n.\r\n") or die; } else { my $offset = 0; my $length = length($$rfc822); while ((my $end = index($$rfc822, "\r\n", $offset) + 2) != 1) { my $line = substr($$rfc822, $offset, $end-$offset); # RFC 5321 sec. 4.5.2: if the line starts with a dot, double it $line = ".".$line if substr($line, 0, 1) eq "."; $SMTP->print($line) or die; $offset = $end; } if ($offset < $length) { # the last line did not end with "\r\n"; add it in order to # have the receiving SMTP server recognize the "end of data" # condition. See RFC 5321 sec. 4.1.1.4 my $line = substr($$rfc822, $offset); $line = ".".$line if substr($line, 0, 1) eq "."; $SMTP->print($line, "\r\n") or die; } $SMTP->printflush(".\r\n") or die; } smtp_resp('250'); } sub smtp_resp($) { my $code = shift; my @resp; while(1) { local $_ = $SMTP->getline() // die; s/\r\n\z// or die "Invalid SMTP reply: $_"; print STDERR "S: $_\n" if $CONFIG{debug}; s/\A\Q$code\E([ -])// or die "SMTP error: Expected $code, got: $_\n"; push @resp, $_; return @resp if $1 eq ' '; } } sub smtp_send(@) { my (@cmd, @code, @r); while (@_) { push @cmd, shift // die; push @code, shift // die; } if ($SMTP_PIPELINING) { # SMTP pipelining (RFC 2920) print STDERR (map {"C: $_\n"} @cmd) if $CONFIG{debug}; $SMTP->printflush(map {"$_\r\n"} @cmd) or die; @r = smtp_resp($_) foreach @code; } else { foreach (@cmd) { print STDERR "C: $_\n" if $CONFIG{debug}; $SMTP->printflush("$_\r\n") or die; @r = smtp_resp(shift(@code)); } } return @r; } ####################################################################### # Initialize the cache from the statefile, then pull new messages from # the remote mailbox # my $IMAP = do { my %config = (%$CONF, %CONFIG{qw/quiet debug/}, name => $ARGV[0]); $config{keepalive} = 1 if defined $CONFIG{idle}; $config{'logger-prefix'} = "%?n?%?m?%n(%m)&%n?: ?"; delete $config{mailbox}; # use SELECTed mailbox in log messages Net::IMAP::InterIMAP::->new( %config ); }; # Remove messages with UID < UIDNEXT and INTERNALDATE at most # $CONF->{'purge-after'} days ago. my $LAST_PURGED; sub purge() { my $days = $CONF->{'purge-after'} // return; my ($uidnext) = $IMAP->get_cache('UIDNEXT'); return unless $days ne '' and 1<$uidnext; my $set = "1:".($uidnext-1); unless ($days == 0) { my $now = time; return if defined $LAST_PURGED and $now - $LAST_PURGED < 43200; # purge every 12h $LAST_PURGED = $now; my @now = gmtime($now - $days*86400); my @m = qw/Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec/; # RFC 3501's date-month my $date = sprintf("%02d-%s-%04d", $now[3], $m[$now[4]], $now[5]+1900); my $ext = $IMAP->incapable('ESEARCH') ? undef : [qw/COUNT ALL/]; my @uid = $IMAP->search((defined $ext ? "RETURN (".join(' ', @$ext).') ' : '') ."UID $set BEFORE $date"); my $count; if (defined $ext) { my ($uid_indicator, %resp) = @uid; $IMAP->panic() unless defined $uid_indicator and $uid_indicator = 'UID'; $count = $resp{COUNT} // $IMAP->panic(); $set = $resp{ALL}; # MUST NOT be present if there are no matches } else { $count = $#uid+1; $set = $count == 0 ? undef : compact_set(@uid); } $IMAP->log("Removing $count UID(s) $set") if $count > 0 and !$CONFIG{quiet}; } if (defined $set) { $IMAP->silent_store($set, '+', '\Deleted'); $IMAP->expunge($set); } # pull messages that have been received in the meantime pull() if $IMAP->has_new_mails($MAILBOX); } # Use BODY.PEEK[] so if something gets wrong, unpulled messages # won't be marked as \Seen in the mailbox my $ATTRS = "ENVELOPE INTERNALDATE"; $ATTRS .= " BODY.PEEK[]" unless $CONFIG{'no-delivery'}; my $RE_ATOM = qr/[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x41-\x5A\x5E-\x7E]+/; my $DOT_STRING = qr/\A$RE_ATOM(?:\.$RE_ATOM)*\z/; sub pull_callback($$) { my ($uids, $mail) = @_; return unless exists $mail->{RFC822} or $CONFIG{'no-delivery'}; # not for us my $uid = $mail->{UID}; my $e = $mail->{ENVELOPE}->[3]; my $sender = ''; if (defined $e and defined (my $l = $e->[0]->[2]) and defined (my $d = $e->[0]->[3])) { if ($l =~ $DOT_STRING) { $sender = $l.'@'.$d; } elsif ($l =~ /\A[\x20-\x7E]*\z/) { # quote the local part if not Dot-string (RFC 5321) $l =~ s/([\x22\x5C])/\\$1/g; # escape double-quote and backslash $sender = '"'.$l.'"@'.$d; } } $IMAP->log("UID $uid from <$sender> ($mail->{INTERNALDATE})") unless $CONFIG{quiet}; sendmail($sender, $mail->{RFC822}) unless $CONFIG{'no-delivery'}; push @$uids, $uid; writeUID($uid); } # Pull new messages from IMAP and deliver them to SMTP, then update the # statefile sub pull(;$) { my $ignore = shift // []; my @uid; my $callback = sub($) { pull_callback(\@uid, shift) }; do { # invariant: we're at pos 8 + 4*(1+$#ignore + 1+$#uids) in the statefile $IMAP->pull_new_messages($ATTRS, $callback, @$ignore); # now that everything has been deliverd, mark @ignore and @uid as \Seen $IMAP->silent_store(compact_set(@$ignore, @uid), '+', '\Seen') if @$ignore or @uid; } # repeat if we got a message in the meantime while ($IMAP->has_new_mails($MAILBOX)); # terminate the SMTP transmission channel gracefully, cf RFC 5321 section 4.5.3.2 smtp_send('QUIT' => '221') if defined $SMTP; undef $SMTP; # update the statefile my $p = sysseek($STATE, 4, SEEK_SET) // die "seek: $!"; die "Couldn't seek to 4" unless $p == 4; # safety check my ($uidnext) = $IMAP->get_cache('UIDNEXT'); writeUID($uidnext); truncate($STATE, 8) // die "truncate: $!"; } do { my $uidvalidity = readUID(); my $uidnext = readUID(); my $ignore = []; $IMAP->set_cache($MAILBOX, UIDVALIDITY => $uidvalidity, UIDNEXT => $uidnext); $IMAP->select($MAILBOX); unless (defined $uidvalidity) { ($uidvalidity) = $IMAP->get_cache('UIDVALIDITY'); # we were at pos 0 before the write, at pos 4 afterwards writeUID($uidvalidity); die if defined $uidnext; # sanity check } if (!defined $uidnext) { # we were at pos 4 before the write, at pos 8 afterwards writeUID(1); } else { # put the remaining UIDs in the @ignore list: these messages # have already been delivered, but the process exited before the # statefile was updated while (defined (my $uid = readUID())) { push @$ignore, $uid; } } pull($ignore); purge(); }; unless (defined $CONFIG{idle}) { $IMAP->logout(); exit 0; } $CONFIG{idle} = 1740 if defined $CONFIG{idle} and $CONFIG{idle} == 0; # 29 mins while(1) { pull() if $IMAP->idle($CONFIG{idle}, \&Net::IMAP::InterIMAP::has_new_mails); purge(); } interimap-0.5.8/pullimap.sample 0000664 0000000 0000000 00000001251 15003201720 0016527 0 ustar 00root root 0000000 0000000 mailbox = INBOX deliver-method = smtp:[127.0.0.1]:25 #deliver-method = smtp:[127.0.0.1]:10024 purge-after = 90 # SSL options #SSL_verify = YES #SSL_protocol_min = TLSv1.2 [private] #type = imaps host = imap.private.org #port = 993 #proxy = socks5h://localhost:9050 username = guilhem password = xxxxxxxxxxxxxxxx #compress = YES #SSL_fingerprint = sha256$d9915f4ad35b76d9eb40f34abd5e8f61edfaad3bf20d5336dc28efa8cc3921ad [work] #type = imaps host = imap.work.com #port = 993 #proxy = socks5h://localhost:9050 username = guilhem password = xxxxxxxxxxxxxxxx #compress = YES #SSL_fingerprint = sha256$3956fd56921c02aeaffe4f8f576e802ee99ab5a096064f4fe7e6a0ad7b4b8a6e # vim:ft=dosini interimap-0.5.8/pullimap@.service 0000664 0000000 0000000 00000000552 15003201720 0017011 0 ustar 00root root 0000000 0000000 [Unit] Description=Pull mails from an IMAP mailbox and deliver them to an SMTP session (instance %i) Documentation=man:pullimap(1) Documentation=https://guilhem.org/interimap/pullimap.1.html Wants=network-online.target After=network-online.target [Service] ExecStart=@bindir@/pullimap --idle %i RestartSec=2min Restart=always [Install] WantedBy=default.target interimap-0.5.8/tests/ 0000775 0000000 0000000 00000000000 15003201720 0014644 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/auth-login/ 0000775 0000000 0000000 00000000000 15003201720 0016713 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/auth-login/interimap.remote 0000664 0000000 0000000 00000000105 15003201720 0022114 0 ustar 00root root 0000000 0000000 type = imap host = localhost port = 10143 STARTTLS = NO auth = login interimap-0.5.8/tests/auth-login/remote.conf 0000664 0000000 0000000 00000000071 15003201720 0021053 0 ustar 00root root 0000000 0000000 !include conf.d/imapd.conf auth_mechanisms = plain login interimap-0.5.8/tests/auth-login/t 0000664 0000000 0000000 00000000501 15003201720 0017075 0 ustar 00root root 0000000 0000000 for ((i = 0; i < 32; i++)); do u="$(shuf -n1 -e "local" "remote")" sample_message | deliver -u "$u" done # check that credentials aren't leaked to the debug output interimap --debug || error grep -Fx "remote: C: xxx LOGIN [REDACTED]" <"$STDERR" || error check_mailbox_status "INBOX" # vim: set filetype=bash : interimap-0.5.8/tests/auth-logindisabled/ 0000775 0000000 0000000 00000000000 15003201720 0020403 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/auth-logindisabled/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0032225 2../auth-sasl-plain/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/auth-logindisabled/remote.conf 0000664 0000000 0000000 00000000202 15003201720 0022537 0 ustar 00root root 0000000 0000000 !include conf.d/imapd.conf # trick dovecot into treating local connections as insecure imap_capability { LOGINDISABLED = yes } interimap-0.5.8/tests/auth-logindisabled/t 0000664 0000000 0000000 00000001423 15003201720 0020571 0 ustar 00root root 0000000 0000000 ! interimap --debug || error # double check the presence of 'LOGINDISABLED' in the preauth capability list grep -oE -m1 '^remote: S: \* OK \[CAPABILITY IMAP4rev1( [^]]*)? AUTH=[^]]*\]' <"$STDERR" >"$TMPDIR/capability" sed -ri 's/^remote: S: \* OK \[CAPABILITY (.*)\]$/\1/' "$TMPDIR/capability" tr " " "\\n" <"$TMPDIR/capability" >"$TMPDIR/capabilities" grep -Fx "IMAP4rev1" <"$TMPDIR/capabilities" || error grep -Fx "LOGINDISABLED" <"$TMPDIR/capabilities" || error ! grep -Fx "STARTTLS" <"$TMPDIR/capabilities" || error # otherwise we'd try to upgrade the connectionn # make sure we didn't send any credentials grep -Fx "remote: ERROR: Logins are disabled." <"$STDERR" || error ! grep -E "^remote: C: .* (AUTHENTICATE|LOGIN) " <"$STDERR" || error # vim: set filetype=bash : interimap-0.5.8/tests/auth-noplaintext/ 0000775 0000000 0000000 00000000000 15003201720 0020150 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/auth-noplaintext/interimap.remote 0000664 0000000 0000000 00000000052 15003201720 0023352 0 ustar 00root root 0000000 0000000 type = imap host = localhost port = 10143 interimap-0.5.8/tests/auth-noplaintext/remote.conf 0000777 0000000 0000000 00000000000 15003201720 0027664 2../auth-sasl-plain/remote.conf ustar 00root root 0000000 0000000 interimap-0.5.8/tests/auth-noplaintext/t 0000664 0000000 0000000 00000001316 15003201720 0020337 0 ustar 00root root 0000000 0000000 ! interimap --debug || error # double check the presence of 'STARTTLS' in the preauth capability list grep -oE -m1 '^remote: S: \* OK \[CAPABILITY IMAP4rev1( [^]]*)? AUTH=[^]]*\]' <"$STDERR" >"$TMPDIR/capability" sed -ri 's/^remote: S: \* OK \[CAPABILITY (.*)\]$/\1/' "$TMPDIR/capability" tr " " "\\n" <"$TMPDIR/capability" >"$TMPDIR/capabilities" grep -Fx "IMAP4rev1" <"$TMPDIR/capabilities" || error ! grep -Fx "STARTTLS" <"$TMPDIR/capabilities" || error # make sure we didn't send any credentials grep -Fx "remote: ERROR: Server did not advertise STARTTLS capability." <"$STDERR" || error ! grep -E "^remote: C: .* (AUTHENTICATE|LOGIN) " <"$STDERR" || error # vim: set filetype=bash : interimap-0.5.8/tests/auth-sasl-plain-no-ir/ 0000775 0000000 0000000 00000000000 15003201720 0020670 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/auth-sasl-plain-no-ir/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0032512 2../auth-sasl-plain/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/auth-sasl-plain-no-ir/remote.conf 0000664 0000000 0000000 00000000120 15003201720 0023023 0 ustar 00root root 0000000 0000000 !include conf.d/imapd.conf !include conf.d/interimap-required-capabilities.conf interimap-0.5.8/tests/auth-sasl-plain-no-ir/t 0000664 0000000 0000000 00000001771 15003201720 0021064 0 ustar 00root root 0000000 0000000 n=1 # at least one message to send remotely sample_message | deliver -u "local" for ((i = 0; i < 32; i++)); do u="$(shuf -n1 -e "local" "remote")" [ "$u" = "remote" ] || n=$(( n+1 )) sample_message | deliver -u "$u" done # check that credentials aren't leaked to the debug output interimap --debug || error grep -Fx "remote: C: xxx AUTHENTICATE PLAIN [REDACTED]" <"$STDERR" || error # make sure we didn't use SASL-IR grep -oE -m1 '^remote: S: \* OK \[CAPABILITY IMAP4rev1( [^]]*)? AUTH=[^]]*\]' <"$STDERR" >"$TMPDIR/capability" sed -ri 's/^remote: S: \* OK \[CAPABILITY (.*)\]$/\1/' "$TMPDIR/capability" tr " " "\\n" <"$TMPDIR/capability" >"$TMPDIR/capabilities" grep -Fx "IMAP4rev1" <"$TMPDIR/capabilities" || error ! grep -Fx "SASL-IR" <"$TMPDIR/capabilities" || error # make sure all literals were synchronizing (and that we didn't use MULTIAPPEND) xcgrep "$n" -E "^remote(\(INBOX\))?: C: [0-9]+ APPEND INBOX .* \{[0-9]+\}$" <"$STDERR" check_mailbox_status "INBOX" # vim: set filetype=bash : interimap-0.5.8/tests/auth-sasl-plain/ 0000775 0000000 0000000 00000000000 15003201720 0017646 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/auth-sasl-plain/interimap.remote 0000664 0000000 0000000 00000000070 15003201720 0023050 0 ustar 00root root 0000000 0000000 type = imap host = localhost port = 10143 STARTTLS = NO interimap-0.5.8/tests/auth-sasl-plain/remote.conf 0000664 0000000 0000000 00000000033 15003201720 0022004 0 ustar 00root root 0000000 0000000 !include conf.d/imapd.conf interimap-0.5.8/tests/auth-sasl-plain/t 0000664 0000000 0000000 00000000516 15003201720 0020036 0 ustar 00root root 0000000 0000000 for ((i = 0; i < 32; i++)); do u="$(shuf -n1 -e "local" "remote")" sample_message | deliver -u "$u" done # check that credentials aren't leaked to the debug output interimap --debug || error grep -Fx "remote: C: xxx AUTHENTICATE PLAIN [REDACTED]" <"$STDERR" || error check_mailbox_status "INBOX" # vim: set filetype=bash : interimap-0.5.8/tests/certs/ 0000775 0000000 0000000 00000000000 15003201720 0015764 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/certs/.gitignore 0000664 0000000 0000000 00000000040 15003201720 0017746 0 ustar 00root root 0000000 0000000 !/generate /*.key /*.crt /*.pem interimap-0.5.8/tests/certs/generate 0000775 0000000 0000000 00000003465 15003201720 0017514 0 ustar 00root root 0000000 0000000 #!/bin/sh set -ue PATH="/usr/bin:/bin" export PATH BASEDIR="$(dirname -- "$0")" OU="InterIMAP test suite" cd "$BASEDIR" OPENSSL_CONF="./openssl.cnf" export OPENSSL_CONF cadir="$(mktemp --tmpdir --directory)" trap 'rm -rf -- "$cadir"' EXIT INT TERM genpkey() { local key="$1" shift openssl genpkey -out "$key" "$@" 2>&1 } # generate CA (we intentionally throw away the private key and serial # file to avoid reuse) genpkey "$cadir/ca.key" -algorithm RSA openssl req -new -x509 -rand /dev/urandom \ -subj "/OU=$OU/CN=Fake Root CA" \ -addext subjectKeyIdentifier="hash" \ -addext authorityKeyIdentifier="keyid:always,issuer" \ -addext basicConstraints="critical,CA:TRUE" \ -key "$cadir/ca.key" -out ./ca.crt SERIAL=1 new() { local key="$1" cn="$2" openssl req -new -rand /dev/urandom -key "$key" \ -subj "/OU=$OU/CN=$cn" ${3+-addext subjectAltName="$3"} \ -out "$cadir/new.csr" cat >"$cadir/new-ext.cnf" <<-EOF basicConstraints = critical, CA:FALSE keyUsage = critical, digitalSignature, keyEncipherment extendedKeyUsage = critical, serverAuth EOF if [ -n "${3+x}" ]; then printf "subjectAltName = %s\\n" "$3" >>"$cadir/new-ext.cnf" fi openssl x509 -req -in "$cadir/new.csr" -CA ./ca.crt -CAkey "$cadir/ca.key" \ -CAserial "$cadir/ca.srl" -CAcreateserial -extfile "$cadir/new-ext.cnf" 2>&1 } genpkey ./dovecot.rsa.key -algorithm RSA new ./dovecot.rsa.key "localhost" "DNS:localhost,DNS:ip6-localhost,IP:127.0.0.1,IP:::1" >./dovecot.rsa.crt genpkey ./dovecot.ecdsa.key -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -pkeyopt ec_param_enc:named_curve new ./dovecot.ecdsa.key "localhost" >./dovecot.ecdsa.crt genpkey ./dovecot.rsa2.key -algorithm RSA new ./dovecot.rsa2.key "imap.example.net" "DNS:imap.example.net,DNS:localhost" >./dovecot.rsa2.crt interimap-0.5.8/tests/certs/openssl.cnf 0000664 0000000 0000000 00000000120 15003201720 0020130 0 ustar 00root root 0000000 0000000 [ req ] distinguished_name = req_distinguished_name [ req_distinguished_name ] interimap-0.5.8/tests/compress/ 0000775 0000000 0000000 00000000000 15003201720 0016477 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/compress/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0030321 2../auth-sasl-plain/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/compress/remote.conf 0000777 0000000 0000000 00000000000 15003201720 0026213 2../auth-sasl-plain/remote.conf ustar 00root root 0000000 0000000 interimap-0.5.8/tests/compress/t 0000664 0000000 0000000 00000001031 15003201720 0016660 0 ustar 00root root 0000000 0000000 for ((i = 0; i < 32; i++)); do u="$(shuf -n1 -e "local" "remote")" sample_message | deliver -u "$u" done # compression enabled by default interimap --debug || error grep -Fx "remote: C: 000001 COMPRESS DEFLATE" <"$STDERR" || error grep -E "^remote: S: 000001 OK( |$)" <"$STDERR" || error check_mailbox_status "INBOX" # can be disabled echo "compress = no" >>"$XDG_CONFIG_HOME/interimap/config" interimap --debug || error ! grep -E "^remote: C: [^[:blank:]]+ COMPRESS DEFLATE$" <"$STDERR" || error # vim: set filetype=bash : interimap-0.5.8/tests/condstore/ 0000775 0000000 0000000 00000000000 15003201720 0016644 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/condstore/t 0000664 0000000 0000000 00000003216 15003201720 0017034 0 ustar 00root root 0000000 0000000 TIMEOUT=60 N=4096 # test CONDSTORE/QRESYNC (behavior) in UID STORE commands, in particular # the UNCHANGEDSINCE test: populate, keep assiging keywords at random, # and make sure interimap is able to reconciliate the changes # populate (with dummy messages to speed things up) only one server # before initializing interimap, so UIDs concide with sequence numbers # and are identical on both servers for ((i = 0; i < N; i++)); do deliver -u "local" <<< . done interimap_init # assign a set of 16 tags; not more because in order to maximize the # likelyhood of conflicts we want UID STORE commands to use large sets declare -a FLAGS=(0 1 2 3 4 5 6 7 8 9 a b c d e f) # start a long-lived interimap process interimap --watch=1 & PID=$! trap "ptree_abort $PID" EXIT INT TERM timer=$(( $(date +%s) + TIMEOUT )) while [ $(date +%s) -le $timer ]; do a="$(shuf -n1 -e "add" "remove" "replace")" u="$(shuf -n1 -e "local" "remote")" f="$(shuf -n1 -e "${FLAGS[@]}")" seqs="$(shuf -n$((N/8)) -i1-$N)" # trigger changes on 1/8 of all messages doveadm -u "$u" flags "$a" --no-userdb-lookup "$f" mailbox "INBOX" "${seqs//$'\n'/,}" sleep "0.0$(shuf -n1 -i10-99)" # 10 to 99ms done sleep 5 ptree_abort $PID trap - EXIT INT TERM # make sure the list of uids for a given tag match flagged_uids() { local u="$1" f="$2" doveadm -u "$u" search --no-userdb-lookup mailbox "INBOX" keyword "$f" | cut -d" " -f2 | sort -n } for f in "${FLAGS[@]}"; do diff --label="local/$f" --label="remote/$f" -u -- \ <(flagged_uids "local" "$f") <(flagged_uids "remote" "$f") || error "UID list differs for keyword '$f'" done # vim: set filetype=bash : interimap-0.5.8/tests/config/ 0000775 0000000 0000000 00000000000 15003201720 0016111 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/config/dovecot/ 0000775 0000000 0000000 00000000000 15003201720 0017554 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/config/dovecot/dhparams.pem 0000664 0000000 0000000 00000000650 15003201720 0022057 0 ustar 00root root 0000000 0000000 -----BEGIN DH PARAMETERS----- MIIBCAKCAQEA0J1dU8erRgIk4bMCBMLezjx32pcQpXrdNgl04dxZVxnJ5Ik2gGhA uQRbbZhAlHNHtFtp9s4TdQ3Ddrv9SuWXYul8U5BWbcxs4nOtwFU8912SfiuVr/kc 4ok2zQ1hdMODtaqWS2ZKBmwcuk4QM6e7fMEAkuZX+Dtf2u8bG5G9B7OL5LggYtrP cFVNQDtfhs64D+sUKJLWkgeg5NH6nbf+0Gs5a8v3/urHKvoxdVScGmKzF+LsFsBm ycQjYeVtA9gLr41mo80rrFysUQqZtNkbdkaXOIA2r9JGTYex1l/XaediR8J94ck9 dwAe2ubRqWcPjmoLJYQIPKiCbvXuJAd0wwIBAg== -----END DH PARAMETERS----- interimap-0.5.8/tests/config/dovecot/imapd.conf 0000664 0000000 0000000 00000000407 15003201720 0021516 0 ustar 00root root 0000000 0000000 protocols { imap = yes } service imap-login { inet_listener imap { port = 10143 } inet_listener imaps { port = 10993 ssl = yes } } # we should avoid sending command lines that are too long imap_max_line_length = 8192 interimap-0.5.8/tests/config/dovecot/interimap-required-capabilities.conf 0000664 0000000 0000000 00000000331 15003201720 0026655 0 ustar 00root root 0000000 0000000 # strict minimum of IMAP capabilities required for interimap to work # (in particular, no LITERAL+, MULTIAPPEND, COMPRESS=DEFLATE, SASL-IR) imap_capability = IMAP4rev1 ENABLE UIDPLUS LIST-EXTENDED QRESYNC LIST-STATUS interimap-0.5.8/tests/config/dovecot/lmtpd.conf 0000664 0000000 0000000 00000000143 15003201720 0021541 0 ustar 00root root 0000000 0000000 protocols { lmtp = yes } service lmtp { inet_listener lmtp { port = 10024 } } interimap-0.5.8/tests/config/dovecot/ssl.conf 0000664 0000000 0000000 00000000276 15003201720 0021231 0 ustar 00root root 0000000 0000000 ssl = required ssl_server_cert_file = dovecot.rsa.crt ssl_server_key_file = dovecot.rsa.key ssl_server_dh_file = dhparams.pem ssl_min_protocol = TLSv1.2 ssl_cipher_list = DEFAULT@SECLEVEL=2 interimap-0.5.8/tests/db-exclusive-lock/ 0000775 0000000 0000000 00000000000 15003201720 0020164 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/db-exclusive-lock/t 0000664 0000000 0000000 00000000744 15003201720 0020357 0 ustar 00root root 0000000 0000000 interimap_init # start a background process interimap --watch=60 & trap "ptree_abort $!" EXIT INT TERM # wait a short while so we have time to lock the database (ugly and racy...) sleep .5 # subsequent runs fail as we can't acquire the exclusive lock ! interimap || error grep -Ex "DBD::SQLite::db do failed: database is locked at (\S+/)?interimap line 181\." <"$STDERR" \ || error "Is \$DBH->do(\"PRAGMA locking_mode = EXCLUSIVE\"); at line 181?" # vim: set filetype=bash : interimap-0.5.8/tests/db-migration-0-1-foreign-key-violation/ 0000775 0000000 0000000 00000000000 15003201720 0023732 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/db-migration-0-1-foreign-key-violation/t 0000664 0000000 0000000 00000001507 15003201720 0024123 0 ustar 00root root 0000000 0000000 interimap_init grep -Fx "database: Created mailbox INBOX" <"$STDERR" || error "INBOX missing from DB" # empty table `mailboxes` and revert its schema to version 0 sqlite3 "$XDG_DATA_HOME/interimap/remote.db" <<-EOF PRAGMA foreign_keys = OFF; PRAGMA user_version = 0; DROP TABLE mailboxes; CREATE TABLE mailboxes ( idx INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, mailbox TEXT NOT NULL CHECK (mailbox != '') UNIQUE, subscribed BOOLEAN NOT NULL ); EOF # now migration must fail due to broken referential integrity ! interimap || error grep -Fx "Upgrading database version from 0" <"$STDERR" || error "DB upgrade not attempted" grep -Fx "database: ERROR: Broken referential integrity! Refusing to commit changes." <"$STDERR" || error "DB upgrade successful despite broken refint" # vim: set filetype=bash : interimap-0.5.8/tests/db-no-create--watch/ 0000775 0000000 0000000 00000000000 15003201720 0020265 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/db-no-create--watch/t 0000664 0000000 0000000 00000000353 15003201720 0020454 0 ustar 00root root 0000000 0000000 ! interimap --watch=60 || error grep -Ex "DBI connect\(.*\) failed: unable to open database file at (\S+/)?interimap line 177\." <"$STDERR" || error test \! -e "$XDG_DATA_HOME/interimap/remote.db" || error # vim: set filetype=bash : interimap-0.5.8/tests/db-upgrade-0-1-delim-mismatch/ 0000775 0000000 0000000 00000000000 15003201720 0022044 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/db-upgrade-0-1-delim-mismatch/before.sql 0000777 0000000 0000000 00000000000 15003201720 0030525 2../db-upgrade-0-1/before.sql ustar 00root root 0000000 0000000 interimap-0.5.8/tests/db-upgrade-0-1-delim-mismatch/local.conf 0000664 0000000 0000000 00000000051 15003201720 0024001 0 ustar 00root root 0000000 0000000 namespace inbox { separator = "\"" } interimap-0.5.8/tests/db-upgrade-0-1-delim-mismatch/remote.conf 0000664 0000000 0000000 00000000046 15003201720 0024206 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ^ } interimap-0.5.8/tests/db-upgrade-0-1-delim-mismatch/t 0000664 0000000 0000000 00000000521 15003201720 0022230 0 ustar 00root root 0000000 0000000 # import an existing non-migrated database sqlite3 "$XDG_DATA_HOME/interimap/remote.db" <"$TESTDIR/before.sql" || error "Couldn't import DB" ! interimap || error grep -Fx 'ERROR: Local and remote hierachy delimiters differ (local "\"", remote "^"), refusing to update table `mailboxes`.' <"$STDERR" || error # vim: set filetype=bash : interimap-0.5.8/tests/db-upgrade-0-1/ 0000775 0000000 0000000 00000000000 15003201720 0017151 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/db-upgrade-0-1/after.sql 0000664 0000000 0000000 00000002376 15003201720 0021003 0 ustar 00root root 0000000 0000000 PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE local (idx INTEGER NOT NULL PRIMARY KEY REFERENCES mailboxes(idx), UIDVALIDITY UNSIGNED INT NOT NULL CHECK (UIDVALIDITY > 0), UIDNEXT UNSIGNED INT NOT NULL, HIGHESTMODSEQ UNSIGNED BIGINT NOT NULL); CREATE TABLE remote (idx INTEGER NOT NULL PRIMARY KEY REFERENCES mailboxes(idx), UIDVALIDITY UNSIGNED INT NOT NULL CHECK (UIDVALIDITY > 0), UIDNEXT UNSIGNED INT NOT NULL, HIGHESTMODSEQ UNSIGNED BIGINT NOT NULL); CREATE TABLE mapping (idx INTEGER NOT NULL REFERENCES mailboxes(idx), lUID UNSIGNED INT NOT NULL CHECK (lUID > 0), rUID UNSIGNED INT NOT NULL CHECK (rUID > 0), PRIMARY KEY (idx,lUID), UNIQUE (idx,rUID)); CREATE TABLE IF NOT EXISTS "mailboxes" (idx INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, mailbox BLOB COLLATE BINARY NOT NULL CHECK (mailbox != '') UNIQUE, subscribed BOOLEAN NOT NULL); INSERT INTO mailboxes VALUES(1,X'61006231006332',0); INSERT INTO mailboxes VALUES(2,X'61006231006331',0); INSERT INTO mailboxes VALUES(3,X'494e424f58',0); INSERT INTO mailboxes VALUES(4,X'6132',0); INSERT INTO mailboxes VALUES(5,X'610062320063',0); DELETE FROM sqlite_sequence; INSERT INTO sqlite_sequence VALUES('mailboxes',5); COMMIT; interimap-0.5.8/tests/db-upgrade-0-1/before.sql 0000664 0000000 0000000 00000002302 15003201720 0021131 0 ustar 00root root 0000000 0000000 PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE mailboxes (idx INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, mailbox TEXT NOT NULL CHECK (mailbox != '') UNIQUE, subscribed BOOLEAN NOT NULL); INSERT INTO mailboxes VALUES(1,'a.b1.c2',0); INSERT INTO mailboxes VALUES(2,'a.b1.c1',0); INSERT INTO mailboxes VALUES(3,'INBOX',0); INSERT INTO mailboxes VALUES(4,'a2',0); INSERT INTO mailboxes VALUES(5,'a.b2.c',0); CREATE TABLE local (idx INTEGER NOT NULL PRIMARY KEY REFERENCES mailboxes(idx), UIDVALIDITY UNSIGNED INT NOT NULL CHECK (UIDVALIDITY > 0), UIDNEXT UNSIGNED INT NOT NULL, HIGHESTMODSEQ UNSIGNED BIGINT NOT NULL); CREATE TABLE remote (idx INTEGER NOT NULL PRIMARY KEY REFERENCES mailboxes(idx), UIDVALIDITY UNSIGNED INT NOT NULL CHECK (UIDVALIDITY > 0), UIDNEXT UNSIGNED INT NOT NULL, HIGHESTMODSEQ UNSIGNED BIGINT NOT NULL); CREATE TABLE mapping (idx INTEGER NOT NULL REFERENCES mailboxes(idx), lUID UNSIGNED INT NOT NULL CHECK (lUID > 0), rUID UNSIGNED INT NOT NULL CHECK (rUID > 0), PRIMARY KEY (idx,lUID), UNIQUE (idx,rUID)); DELETE FROM sqlite_sequence; INSERT INTO sqlite_sequence VALUES('mailboxes',5); COMMIT; interimap-0.5.8/tests/db-upgrade-0-1/local.conf 0000664 0000000 0000000 00000000046 15003201720 0021112 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/db-upgrade-0-1/remote.conf 0000777 0000000 0000000 00000000000 15003201720 0023247 2local.conf ustar 00root root 0000000 0000000 interimap-0.5.8/tests/db-upgrade-0-1/t 0000664 0000000 0000000 00000002423 15003201720 0017340 0 ustar 00root root 0000000 0000000 # create the mailboxes from the database doveadm -u "local" mailbox create --no-userdb-lookup "a.b1.c1" "a.b1.c2" "a.b2.c" "a2" doveadm -u "remote" mailbox create --no-userdb-lookup "a.b1.c1" "a.b1.c2" "a.b2.c" "a2" # import an existing non-migrated database sqlite3 "$XDG_DATA_HOME/interimap/remote.db" <"$TESTDIR/before.sql" || error "Couldn't import DB" # migrate interimap || error "Couldn't upgrade DB" grep -Fx "Upgrading database version from 0" <"$STDERR" || error "Couldn't upgrade DB" check_mailboxes_status "a.b1.c1" "a.b1.c2" "a.b2.c" "a2" # verify that the new schema is as expected sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/dump.sql" <<-EOF DELETE FROM local; DELETE FROM remote; .dump EOF # re-import and dump the expected dump to work around SQLite format # differences across versions sqlite3 "$XDG_DATA_HOME/interimap/remote2.db" <"$TESTDIR/after.sql" sqlite3 "$XDG_DATA_HOME/interimap/remote2.db" >"$TMPDIR/dump-expected.sql" <<-EOF .dump EOF # XXX need 'user_version' PRAGMA in the dump for future migrations # http://sqlite.1065341.n5.nabble.com/dump-command-and-user-version-td101228.html diff -u --label="a/dump.sql" --label="b/dump.sql" \ "$TMPDIR/dump-expected.sql" "$TMPDIR/dump.sql" \ || error "DB dumps differ" # vim: set filetype=bash : interimap-0.5.8/tests/delete/ 0000775 0000000 0000000 00000000000 15003201720 0016106 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/delete/local.conf 0000664 0000000 0000000 00000000046 15003201720 0020047 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/delete/remote.conf 0000664 0000000 0000000 00000000046 15003201720 0020250 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ^ } interimap-0.5.8/tests/delete/t 0000664 0000000 0000000 00000006046 15003201720 0016302 0 ustar 00root root 0000000 0000000 doveadm -u "local" mailbox create --no-userdb-lookup "foo.bar" "foo.bar.baz" for m in "foo.bar" "foo.bar.baz" "INBOX"; do sample_message | deliver -u "local" -- -m "$m" done interimap_init check_mailbox_list check_mailboxes_status "foo.bar" "foo.bar.baz" "INBOX" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/dump.sql" <<-EOF .dump EOF step_start "nonexistent source (no-op)" interimap --target="local,remote" --target="database" --delete "nonexistent" || error check_mailbox_list check_mailboxes_status "foo.bar" "foo.bar.baz" "INBOX" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/dump2.sql" <<-EOF .dump EOF diff -u --label="a/dump.sql" --label="b/dump.sql" \ "$TMPDIR/dump.sql" "$TMPDIR/dump2.sql" || error "SQL dumps differ" step_done # foo.bar will become \NoSelect in local, per RFC 3501: "It is permitted # to delete a name that has inferior hierarchical names and does not # have the \Noselect mailbox name attribute. In this case, all messages # in that mailbox are removed, and the name will acquire the \Noselect # mailbox name attribute." step_start "mailbox with inferiors" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/mailboxes.sql" <<-EOF SELECT idx, mailbox FROM mailboxes WHERE mailbox != x'$(printf "%s\\0%s" "foo" "bar" | xxd -ps)' ORDER BY idx EOF interimap --target="local" --delete "foo.bar" check_mailbox_list check_mailboxes_status "foo.bar.baz" "INBOX" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/dump2.sql" <<-EOF .dump EOF diff -u --label="a/dump.sql" --label="b/dump.sql" \ "$TMPDIR/dump.sql" "$TMPDIR/dump2.sql" || error "SQL dumps differ" ! doveadm -u "local" mailbox status --no-userdb-lookup uidvalidity "foo.bar" # gone doveadm -u "remote" mailbox status --no-userdb-lookup uidvalidity "foo^bar" # now delete from the remote server and the database interimap --delete "foo.bar" ! doveadm -u "local" mailbox status --no-userdb-lookup uidvalidity "foo.bar" ! doveadm -u "remote" mailbox status --no-userdb-lookup uidvalidity "foo^bar" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/mailboxes2.sql" <<-EOF SELECT idx, mailbox FROM mailboxes ORDER BY idx EOF diff -u --label="a/mailboxes.sql" --label="b/mailboxes.sql" \ "$TMPDIR/mailboxes.sql" "$TMPDIR/mailboxes2.sql" || error "SQL dumps differ" check_mailbox_list check_mailboxes_status "foo.bar.baz" "INBOX" step_done step_start "INBOX (fail)" ! interimap --delete "InBoX" || error "deleted INBOX" grep -Fx "ERROR: INBOX can't be deleted" <"$STDERR" || error check_mailbox_list check_mailboxes_status "foo.bar.baz" "INBOX" step_done step_start "\\Noinferiors mailbox" interimap --delete "foo.bar.baz" ! doveadm -u "local" mailbox status --no-userdb-lookup uidvalidity "foo.bar.baz" ! doveadm -u "remote" mailbox status --no-userdb-lookup uidvalidity "foo^bar^baz" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/count" <<-EOF SELECT COUNT(*) FROM mailboxes EOF [ "$(< "$TMPDIR/count" )" -eq 1 ] || error "Not only INBOX left?" check_mailbox_list check_mailboxes_status "INBOX" step_done # vim: set filetype=bash : interimap-0.5.8/tests/delimiter-change/ 0000775 0000000 0000000 00000000000 15003201720 0020045 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/delimiter-change/local.conf 0000777 0000000 0000000 00000000000 15003201720 0026561 2../list-mailbox/local.conf ustar 00root root 0000000 0000000 interimap-0.5.8/tests/delimiter-change/remote.conf 0000777 0000000 0000000 00000000000 15003201720 0027163 2../list-mailbox/remote.conf ustar 00root root 0000000 0000000 interimap-0.5.8/tests/delimiter-change/t 0000664 0000000 0000000 00000002645 15003201720 0020242 0 ustar 00root root 0000000 0000000 # create and populate some mailboxes doveadm -u "local" mailbox create --no-userdb-lookup -- "foo" "foo.bar" "baz" run() { local i m u s1="$1" s2="$2" m2 for ((i = 0; i < 16; i++)); do m="$(shuf -n1 -e -- "foo" "foo${s1}bar" "baz" "INBOX")" u="$(shuf -n1 -e -- "local" "remote")" [ "$u" = "local" ] && m2="$m" || m2="${m//"$s1"/$s2}" sample_message | deliver -u "$u" -- -m "$m2" done interimap --debug grep -Fxq "local: Using \"$s1\" as hierarchy delimiter" <"$STDERR" || error grep -Fxq "remote: Using \"$s2\" as hierarchy delimiter" <"$STDERR" || error check_mailbox_list "foo" "foo${s1}bar" "baz" "INBOX" check_mailboxes_status "foo" "foo${s1}bar" "baz" "INBOX" || error } run "." "?" # make sure interimap doesn't choke when the hierarchy delimiter changes # cf. https://www.imapwiki.org/ClientImplementation/MailboxList#Hierarchy_separators sed -ri "s,^(\\s*separator\\s*)=.*,separator = /," "$HOME_remote/.dovecot/config" run "." "/" sed -ri "s,^(\\s*separator\\s*)=.*,separator = /," "$HOME_local/.dovecot/config" run "/" "/" sed -ri "s,^(\\s*separator\\s*)=.*,separator = .," "$HOME_local/.dovecot/config" sed -ri "s,^(\\s*separator\\s*)=.*,separator = .," "$HOME_remote/.dovecot/config" run "." "." # ensure there were no duplicates n="$(doveadm -u "local" search --no-userdb-lookup all | wc -l)" [ "$n" -eq 64 ] || error "$n != 64" # vim: set filetype=bash : interimap-0.5.8/tests/ignore-mailbox/ 0000775 0000000 0000000 00000000000 15003201720 0017560 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/ignore-mailbox/interimap.conf 0000664 0000000 0000000 00000000044 15003201720 0022415 0 ustar 00root root 0000000 0000000 ignore-mailbox = ^virtual(?:\x00|$) interimap-0.5.8/tests/ignore-mailbox/local.conf 0000664 0000000 0000000 00000000046 15003201720 0021521 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/ignore-mailbox/remote.conf 0000664 0000000 0000000 00000000046 15003201720 0021722 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ^ } interimap-0.5.8/tests/ignore-mailbox/t 0000664 0000000 0000000 00000004464 15003201720 0017756 0 ustar 00root root 0000000 0000000 doveadm -u "local" mailbox create --no-userdb-lookup "foo" -- "-virtual" doveadm -u "remote" mailbox create --no-userdb-lookup "bar" -- "virtual-" interimap_init check_mailbox_list sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/mailboxes.sql" <<-EOF SELECT idx, mailbox FROM mailboxes ORDER BY idx EOF for ((i = 0; i < 16; i++)); do u="$(shuf -n1 -e "local" "remote")" # choose target at random m="$(shuf -n1 -e -- "INBOX" "foo" "bar")" sample_message | deliver -u "$u" -- -m "$m" done # create new mailboxes matching 'ignore-mailbox' doveadm -u "local" mailbox create --no-userdb-lookup "virtual" "virtual.foo" doveadm -u "remote" mailbox create --no-userdb-lookup "virtual^bar" for n in $(seq 1 "$(shuf -n1 -i1-8)"); do sample_message | deliver -u "local" -- -m "virtual" sample_message | deliver -u "local" -- -m "virtual.foo" done for n in $(seq 1 "$(shuf -n1 -i1-8)"); do sample_message | deliver -u "remote" -- -m "virtual^bar" done # no new mailbox should be created interimap || error sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/mailboxes2.sql" <<-EOF SELECT idx, mailbox FROM mailboxes ORDER BY idx EOF diff -u --label="a/mailboxes.sql" --label="b/mailboxes.sql" \ "$TMPDIR/mailboxes.sql" "$TMPDIR/mailboxes2.sql" || error "SQL dumps differ" check_mailboxes_status "INBOX" "foo" "bar" # double check the unsubscribed mailboxes weren't copied ! doveadm -u "remote" mailbox status uidvalidity "virtual" || error ! doveadm -u "remote" mailbox status uidvalidity "virtual^foo" || error ! doveadm -u "local" mailbox status uidvalidity "virtual.bar" || error # ignored mailboxes are created when passed to the command line interimap "virtual" "virtual.bar" || error grep -Fx "database: Created mailbox virtual" <"$STDERR" || error grep -Fx "database: Created mailbox virtual.bar" <"$STDERR" || error grep -Fx "local: Created mailbox virtual.bar" <"$STDERR" || error grep -Fx "remote: Created mailbox virtual" <"$STDERR" || error sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/mailboxes.sql" <<-EOF SELECT idx, mailbox FROM mailboxes WHERE mailbox != x'$(printf "virtual" | xxd -ps)' AND mailbox != x'$(printf "%s\\0%s" "virtual" "foo" | xxd -ps)' ORDER BY idx EOF check_mailboxes_status "virtual" "virtual.bar" || error # vim: set filetype=bash : interimap-0.5.8/tests/interimap.list 0000664 0000000 0000000 00000005007 15003201720 0017533 0 ustar 00root root 0000000 0000000 db-no-create--watch `interimap --watch` refuses to create the database db-exclusive-lock mutually exclusive DB access . DB schema upgrade (v0 -> v1) db-upgrade-0-1 migrate # may happen if the server(s) software or its configuration changed db-upgrade-0-1-delim-mismatch abort on hierarchy delimiter mismatch # foreign key checking was broken until v0.5 db-migration-0-1-foreign-key-violation abort on foreign key contraint violation . Mailbox deletion ... delete . Mailbox renaming rename-exists-db abort if target exists in the DB rename-exists-local abort if target exists locally rename-exists-remote abort if target exists remotely ... rename-simple ... rename-inferiors # try values beyond the signed integer limit largeint Large UIDVALIDITY/UIDNEXT/HIGHESTMODSEQ values . Mailbox synchronization ... sync-mailbox-list list-reference list-reference list-mailbox list-mailbox = foo "foo bar" "f\\\"o\x21o.*" "f\0o\0o" list-select-opts list-select-opts = SUBSCRIBED ignore-mailbox ignore-mailbox = ^virtual(?:\x00|$) delimiter-change doesn't choke on delimiter change resume Resume when aborted repair --repair . Authentication auth-sasl-plain AUTHENTICATE (SASL PLAIN) auth-sasl-plain-no-ir AUTHENTICATE (SASL PLAIN, no SASL-IR) auth-login LOGIN auth-logindisabled LOGINDISABLED auth-noplaintext abort when STARTTLS is not offered preauth-plaintext abort on MiTM via PREAUTH greeting compress COMPRESS=DEFLATE condstore CONDSTORE split-set Split large sets to avoid extra-long command lines . SSL/TLS starttls-logindisabled LOGINDISABLED STARTTLS starttls STARTTLS starttls-injection STARTTLS response injection tls SSL/TLS handshake ... tls-verify-peer tls-pin-fingerprint pubkey fingerprint pinning tls-rsa+ecdsa pubkey fingerprint pinning for dual-cert RSA+ECDSA tls-sni TLS servername extension (SNI) tls-protocols force TLS protocol versions tls-ciphers force TLS cipher list/suites . Live synchronization (60s) sync-live local/remote simulation sync-live-crippled local/remote simulation (crippled remote) sync-live-tls local/remote simulation (TLS remote) sync-live-multi local/remote1+remote2+remote3 simulation (3 local namespaces) interimap-0.5.8/tests/largeint/ 0000775 0000000 0000000 00000000000 15003201720 0016451 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/largeint/t 0000664 0000000 0000000 00000004725 15003201720 0016647 0 ustar 00root root 0000000 0000000 doveadm -u "local" mailbox create --no-userdb-lookup "foo" "bar" "baz" doveadm -u "remote" mailbox create --no-userdb-lookup "foo" "bar" "baz" doveadm -u "local" mailbox update --no-userdb-lookup --uid-validity 1 "INBOX" doveadm -u "local" mailbox update --no-userdb-lookup --uid-validity 2147483647 "foo" # 2^31-1 doveadm -u "local" mailbox update --no-userdb-lookup --uid-validity 2147483648 "bar" # 2^31 doveadm -u "local" mailbox update --no-userdb-lookup --uid-validity 4294967295 "baz" # 2^32-1 doveadm -u "remote" mailbox update --no-userdb-lookup --uid-validity 4294967295 "INBOX" # 2^32-1 doveadm -u "remote" mailbox update --no-userdb-lookup --uid-validity 2147483648 "foo" # 2^31 doveadm -u "remote" mailbox update --no-userdb-lookup --uid-validity 2147483647 "bar" # 2^31-1 doveadm -u "remote" mailbox update --no-userdb-lookup --uid-validity 1 "baz" # run() { local u m i for ((i = 0; i < 64; i++)); do u="$(shuf -n1 -e "local" "remote")" # choose target at random m="$(shuf -n1 -e -- "INBOX" "foo" "bar" "baz")" sample_message | deliver -u "$u" -- -m "$m" done interimap || error check_mailbox_list check_mailbox_status "INBOX" "foo" "bar" "baz" } run # raise UIDNEXT AND HIGHESTMODSEQ close to the max values (resp. 2^32-1 och 2^63-1) # XXX as of dovecot 2.4 --min-highest-modseq rejects values higher than INT64_MAX instead of UINT64_MAX, # cf. str_parse_int64() doveadm -u "local" mailbox update --no-userdb-lookup --min-next-uid 2147483647 --min-highest-modseq 9223372036854775807 "INBOX" # 2^31-1, 2^63-1 doveadm -u "local" mailbox update --no-userdb-lookup --min-next-uid 2147483647 --min-highest-modseq 9223372036854775807 "foo" # 2^31-1, 2^63-1 doveadm -u "local" mailbox update --no-userdb-lookup --min-next-uid 2147483648 --min-highest-modseq 9223372036854775807 "bar" # 2^31, 2^63-1 doveadm -u "local" mailbox update --no-userdb-lookup --min-next-uid 2147483648 --min-highest-modseq 9223372036854775807 "baz" # 2^31, 2^63-1 doveadm -u "remote" mailbox update --no-userdb-lookup --min-next-uid 4294967168 --min-highest-modseq 9223372036854775807 "INBOX" # 2^32-128, 2^63-1 doveadm -u "remote" mailbox update --no-userdb-lookup --min-next-uid 2147483776 --min-highest-modseq 9223372036854775807 "foo" # 2^31+128, 2^63-1 doveadm -u "remote" mailbox update --no-userdb-lookup --min-next-uid 2147483648 --min-highest-modseq 9223372036854775807 "bar" # 2^31, 2^63-1 run # vim: set filetype=bash : interimap-0.5.8/tests/list-mailbox/ 0000775 0000000 0000000 00000000000 15003201720 0017250 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/list-mailbox/interimap.conf 0000664 0000000 0000000 00000000067 15003201720 0022112 0 ustar 00root root 0000000 0000000 list-mailbox = foo "foo bar" "f\\\"o\x21o.*" "f\0o\0o" interimap-0.5.8/tests/list-mailbox/local.conf 0000664 0000000 0000000 00000000046 15003201720 0021211 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/list-mailbox/remote.conf 0000664 0000000 0000000 00000000046 15003201720 0021412 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ? } interimap-0.5.8/tests/list-mailbox/t 0000664 0000000 0000000 00000004222 15003201720 0017436 0 ustar 00root root 0000000 0000000 # create and populate some mailboxes locally declare -a MAILBOXES=( "foo" "foo bar" "f\\\"o!o.bar" "f.o.o" ) doveadm -u "local" mailbox create --no-userdb-lookup -- "${MAILBOXES[@]}" "foobad" "baz" "INBOX" for ((i = 0; i < 32; i++)); do m="$(shuf -n1 -e -- "${MAILBOXES[@]}" "foobad" "baz" "INBOX")" sample_message | deliver -u "local" -- -m "$m" done interimap_init for m in "${MAILBOXES[@]}"; do grep -Fx "remote: Created mailbox ${m//./?}" <"$STDERR" || error "${m//./?}" grep -Fx "database: Created mailbox $m" <"$STDERR" || error done # also check inferiors in the list, but exclude "foobad" and "baz" check_mailbox_list "${MAILBOXES[@]}" "INBOX" "f\\\"o!o" "f" "f.o" check_mailboxes_status "${MAILBOXES[@]}" || error # double check that "foobad" and "baz" weren't created ! doveadm -u "remote" mailbox status uidvalidity "foobad" || error ! doveadm -u "remote" mailbox status uidvalidity "baz" || error # check that "foobad" and "INBOX" aren't in the database sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/count" <<-EOF SELECT COUNT(*) FROM mailboxes WHERE mailbox = x'$(printf "%s" "foobad" | xxd -u -ps)' OR mailbox = x'$(printf "%s" "INBOX" | xxd -u -ps)' OR mailbox = x'$(printf "%s" "baz" | xxd -u -ps)' EOF [ $(< "$TMPDIR/count") -eq 0 ] || error # mailbox given on the command line overrides list-mailbox sample_message | deliver -u "local" -- -m "foobad" sample_message | deliver -u "local" -- -m "foo" interimap "foobad" || error ! grep -F "remote(foo): Added 1 UID(s)" <"$STDERR" || error check_mailbox_list "foobad" check_mailbox_status "foobad" interimap "foo" || error grep -F "remote(foo): Added 1 UID(s)" <"$STDERR" || error check_mailbox_status "foo" ! check_mailbox_list "baz" # finally, try a bunch of invalid 'list-mailbox' values to test the parser: # empty string, missing space between values, unterminated string for v in '""' '"f o o""bar"' '"f o o" "bar" "baz\" x'; do sed -ri "s/^(list-mailbox\\s*=\\s*).*/\\1${v//\\/\\\\}/" \ "$XDG_CONFIG_HOME/interimap/config" ! interimap || error grep -xF "Invalid value for list-mailbox: $v" <"$STDERR" done # vim: set filetype=bash : interimap-0.5.8/tests/list-reference/ 0000775 0000000 0000000 00000000000 15003201720 0017553 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/list-reference/interimap.local 0000664 0000000 0000000 00000000026 15003201720 0022555 0 ustar 00root root 0000000 0000000 list-reference = foo/ interimap-0.5.8/tests/list-reference/interimap.remote 0000664 0000000 0000000 00000000026 15003201720 0022756 0 ustar 00root root 0000000 0000000 list-reference = bar\ interimap-0.5.8/tests/list-reference/local.conf 0000664 0000000 0000000 00000000046 15003201720 0021514 0 ustar 00root root 0000000 0000000 namespace inbox { separator = / } interimap-0.5.8/tests/list-reference/remote.conf 0000664 0000000 0000000 00000000051 15003201720 0021711 0 ustar 00root root 0000000 0000000 namespace inbox { separator = "\\" } interimap-0.5.8/tests/list-reference/t 0000664 0000000 0000000 00000003515 15003201720 0017745 0 ustar 00root root 0000000 0000000 # create and populate some mailboxes in and out the respective list references doveadm -u "local" mailbox create --no-userdb-lookup "foo" "foobar" "foo/bar/baz" "foo/baz" "bar" "bar/baz" doveadm -u "remote" mailbox create --no-userdb-lookup "foo" "foobaz" "foo\\bar" "foo\\baz" "bar\\baz" "bar\\!" populate() { local i for ((i = 0; i < 32; i++)); do m="$(shuf -n1 -e -- "foo" "foobar" "foo/bar/baz" "foo/baz" "bar" "bar/baz")" sample_message | deliver -u "local" -- -m "$m" m="$(shuf -n1 -e -- "foo" "foobaz" "foo\\bar" "foo\\baz" "bar\\baz" "bar\\!")" sample_message | deliver -u "remote" -- -m "$m" done } populate interimap_init grep -Fx "database: Created mailbox bar/baz" <"$STDERR" || error grep -Fx "database: Created mailbox baz" <"$STDERR" || error grep -Fx "database: Created mailbox !" <"$STDERR" || error grep -Fx "local: Created mailbox foo/!" <"$STDERR" || error grep -Fx "remote: Created mailbox bar\\bar\\baz" <"$STDERR" || error verify() { # check that the mailbox lists match diff -u --label="local/mailboxes" --label="remote/mailboxes" \ <( doveadm -u "local" mailbox list | sed -n 's,^foo/,,p' | sort ) \ <( doveadm -u "remote" mailbox list | sed -n 's,^bar\\,,p' | tr '\\' '/' | sort ) \ || error "mailbox lists differ" for m in "bar/baz" "baz" "!"; do blob="x'$(printf "%s" "$m" | tr "/" "\\0" | xxd -c256 -u -ps)'" check_mailbox_status2 "$blob" "foo/$m" "remote" "bar\\${m//\//\\}" done } verify # add more messages and re-check populate interimap || error verify # double check that mailboxes outside references weren't created ! doveadm -u "local" mailbox status uidvalidity "foobaz" || error ! doveadm -u "remote" mailbox status uidvalidity "foobar" || error # vim: set filetype=bash : interimap-0.5.8/tests/list-select-opts/ 0000775 0000000 0000000 00000000000 15003201720 0020057 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/list-select-opts/interimap.conf 0000664 0000000 0000000 00000000036 15003201720 0022715 0 ustar 00root root 0000000 0000000 list-select-opts = SUBSCRIBED interimap-0.5.8/tests/list-select-opts/local.conf 0000664 0000000 0000000 00000000046 15003201720 0022020 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/list-select-opts/remote.conf 0000664 0000000 0000000 00000000046 15003201720 0022221 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ^ } interimap-0.5.8/tests/list-select-opts/t 0000664 0000000 0000000 00000003712 15003201720 0020250 0 ustar 00root root 0000000 0000000 doveadm -u "local" mailbox create --no-userdb-lookup -s "INBOX" "foo.bar" doveadm -u "remote" mailbox create --no-userdb-lookup -s "INBOX" "bar" interimap_init check_mailbox_list sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/mailboxes.sql" <<-EOF SELECT idx, mailbox FROM mailboxes ORDER BY idx EOF for ((i = 0; i < 16; i++)); do u="$(shuf -n1 -e "local" "remote")" # choose target at random m="$(shuf -n1 -e -- "INBOX" "foo.bar" "bar")" sample_message | deliver -u "$u" -- -m "$m" done # create new unsubscribed mailboxes doveadm -u "local" mailbox create --no-userdb-lookup "foo" doveadm -u "remote" mailbox create --no-userdb-lookup "baz" for ((i = 0; i < 8; i++)); do u="$(shuf -n1 -e "local" "remote")" # choose target at random [ u="local" ] && m="foo" || m="baz" sample_message | deliver -u "$u" -- -m "$m" done # no new mailbox should be created interimap || error sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/mailboxes2.sql" <<-EOF SELECT idx, mailbox FROM mailboxes ORDER BY idx EOF diff -u --label="a/mailboxes.sql" --label="b/mailboxes.sql" \ "$TMPDIR/mailboxes.sql" "$TMPDIR/mailboxes2.sql" || error "SQL dumps differ" check_mailboxes_status "INBOX" "foo.bar" "bar" # double check the unsubscribed mailboxes weren't copied ! doveadm -u "remote" mailbox status --no-userdb-lookup uidvalidity "foo" || error ! doveadm -u "local" mailbox status --no-userdb-lookup uidvalidity "baz" || error # reconcile when subcribed doveadm -u "local" mailbox subscribe --no-userdb-lookup "foo" doveadm -u "remote" mailbox subscribe --no-userdb-lookup "baz" interimap || error grep -Fx "database: Created mailbox foo" <"$STDERR" || error grep -Fx "database: Created mailbox baz" <"$STDERR" || error grep -Fx "local: Created mailbox baz" <"$STDERR" || error grep -Fx "remote: Created mailbox foo" <"$STDERR" || error check_mailbox_list check_mailboxes_status "INBOX" "foo" "foo.bar" "bar" "baz" # vim: set filetype=bash : interimap-0.5.8/tests/preauth-plaintext/ 0000775 0000000 0000000 00000000000 15003201720 0020322 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/preauth-plaintext/imapd 0000775 0000000 0000000 00000002546 15003201720 0021351 0 ustar 00root root 0000000 0000000 #!/usr/bin/perl -T use warnings; use strict; use Errno qw/EINTR/; use Socket qw/INADDR_LOOPBACK AF_INET SOCK_STREAM pack_sockaddr_in SOL_SOCKET SO_REUSEADDR SHUT_RDWR/; socket(my $S, AF_INET, SOCK_STREAM, 0) or die; setsockopt($S, SOL_SOCKET, SO_REUSEADDR, pack("l", 1)) or die; bind($S, pack_sockaddr_in(10143, INADDR_LOOPBACK)) or die "bind: $!\n"; listen($S, 1) or die "listen: $!"; while (1) { my $sockaddr = accept(my $conn, $S) or do { next if $! == EINTR; die "accept: $!"; }; # minimum CAPABILITY list, see tests/config/dovecot/interimap-required-capabilities.conf $conn->printflush("* PREAUTH [CAPABILITY IMAP4rev1 ENABLE UIDPLUS LIST-EXTENDED QRESYNC LIST-STATUS] IMAP4rev1 Server\r\n"); my $x; $x = $conn->getline() // next; $x =~ /\A(\S+) ENABLE QRESYNC\r\n/ or die; $conn->printflush("* ENABLED QRESYNC\r\n$1 OK ENABLE completed\r\n"); $x = $conn->getline() // next; $x =~ /\A(\S+) LIST .*\r\n/ or die; $conn->print("* LIST (\\Noselect) \"~\" \"\"\r\n"); $conn->print("* LIST () \"~\" INBOX\r\n"); $conn->print("* STATUS INBOX (UIDNEXT 1 UIDVALIDITY 1 HIGHESTMODSEQ 1)\r\n"); $conn->printflush("$1 OK LIST completed\r\n"); close($conn); } END { if (defined $S) { shutdown($S, SHUT_RDWR) or warn "shutdown: $!"; close($S) or print STDERR "close: $!\n"; } } interimap-0.5.8/tests/preauth-plaintext/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0031022 2../starttls/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/preauth-plaintext/t 0000664 0000000 0000000 00000001463 15003201720 0020514 0 ustar 00root root 0000000 0000000 # Test IMAP MiTM via PREAUTH greeting # For background see CVE-2020-12398, CVE-2020-14093 and # https://gitlab.com/muttmua/mutt/commit/3e88866dc60b5fa6aaba6fd7c1710c12c1c3cd01 env -i USER="remote" HOME="$HOME_remote" "$TESTDIR/imapd" & PID=$! trap "ptree_abort $PID" EXIT INT TERM ! interimap --debug || error grep -Fx 'remote: ERROR: PREAUTH greeting on plaintext connection? MiTM in action? Aborting, set "STARTTLS = NO" to ignore.' <"$STDERR" || error ! grep '^remote: C: ' <"$STDERR" || error "wrote command in MiTM'ed PREAUTH connection!" # Ignore the warning when STARTTLS is explicitly disabled echo "STARTTLS = NO" >>"$XDG_CONFIG_HOME/interimap/config" interimap --debug || true grep -Fx "remote: S: * STATUS INBOX (UIDNEXT 1 UIDVALIDITY 1 HIGHESTMODSEQ 1)" <"$STDERR" || error # vim: set filetype=bash : interimap-0.5.8/tests/pullimap.list 0000664 0000000 0000000 00000000034 15003201720 0017361 0 ustar 00root root 0000000 0000000 . pullimap ... pullimap interimap-0.5.8/tests/pullimap/ 0000775 0000000 0000000 00000000000 15003201720 0016467 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/pullimap/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0026111 2../tls/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/pullimap/local.conf 0000664 0000000 0000000 00000000033 15003201720 0020424 0 ustar 00root root 0000000 0000000 !include conf.d/lmtpd.conf interimap-0.5.8/tests/pullimap/pullimap.conf 0000664 0000000 0000000 00000000050 15003201720 0021154 0 ustar 00root root 0000000 0000000 deliver-method = lmtp:[127.0.0.1]:10024 interimap-0.5.8/tests/pullimap/remote.conf 0000777 0000000 0000000 00000000000 15003201720 0024003 2../tls/remote.conf ustar 00root root 0000000 0000000 interimap-0.5.8/tests/pullimap/t 0000664 0000000 0000000 00000011144 15003201720 0016656 0 ustar 00root root 0000000 0000000 MAILBOX="INBOX" TIMEOUT=60 N=2048 step_start "\`pullimap --idle\` refuses to create the state file" ! pullimap --idle "remote" || error step_done step_start "\`pullimap\` creates statefile with mode 0600" pullimap "remote" || error if ! st="$(stat -c"%#a" -- "$XDG_DATA_HOME/pullimap/remote")" || [ "$st" != "0600" ]; then error "$XDG_DATA_HOME/pullimap/remote has mode $st != 0600" fi step_done step_start "\`pullimap\` locks its statefile" pullimap --idle "remote" & PID=$! trap "ptree_abort $PID" EXIT INT TERM # wait a short while so we have time to lock the database (ugly and racy...) sleep .5 ! pullimap "remote" || error grep -F "Can't lock $XDG_DATA_HOME/pullimap/remote: Resource temporarily unavailable at " <"$STDERR" || error ptree_abort $PID trap - EXIT INT TERM step_done # compare mailboxes (can't compare the RFC 3501 TEXT as the LMTPd inconditionally # adds a Return-Path: header -- and also Delivered-To: and Received: to by default) list_mails_sha256() { local u="$1" guid uid local fields="body date.sent imap.bodystructure imap.envelope" while read guid uid; do doveadm -u "$u" -f "flow" fetch --no-userdb-lookup "$fields" mailbox-guid "$guid" uid "$uid" | sha256sum done < <(doveadm -u "$u" search mailbox --no-userdb-lookup "$MAILBOX") | sort -f } check() { diff -u --label="local/mails" --label="remote/mails" \ <( list_mails_sha256 "local" ) \ <( list_mails_sha256 "remote" ) \ || error "mailboxes differ" } message_from() { local date="$(date +"%s.%N")" sender="$1" cat <<-EOF From: $sender To: Date: $(date -R -d@"$date") Message-ID: <$date@example.net> EOF xxd -ps -l8 /dev/urandom } step_start "Quote envelope sender address" declare -a senders=("sender" "first.last" "foo-bar" \"\" "\"x\\\" #&\\\\y\"") for s in "${senders[@]}"; do message_from "$s@example.net" | deliver -u "remote" -- -m "$MAILBOX" done pullimap "remote" || error check for s in "${senders[@]}"; do grep -F " from <$s@example.net> " <"$STDERR" || error "$s" done step_done step_start "Mail without data" deliver -u "remote" -- -m "$MAILBOX" foo . .bar ..baz EOF # we can't add a test for message data not ending with CRLF, because the # LMTP/SMTP client needs to add a CRLF so local and remote message # bodies would differ. that said, while such a message could be added # by IMAP and LDA, it's not valid for SMTP (RFC 5321 sec. 4.1.1.4) pullimap "remote" || error check step_done # make sure remote UIDs are 11-bytes long doveadm -u "remote" mailbox update --no-userdb-lookup --min-next-uid 1000000000 "$MAILBOX" # Add some messages and sync step_start "Fetching messages" for ((i = 0; i < 32; i++)); do sample_message | deliver -u "remote" -- -m "$MAILBOX" done pullimap "remote" || error check # same thing, but with some missing messages for ((i = 0; i < N; i+=2)); do sample_message | deliver -u "remote" -- -m "$MAILBOX" deliver -u "remote" -- -m "$MAILBOX" "$TMPDIR/unseen" [ ! -s "$TMPDIR/unseen" ] || error "\\Unseen messages left" step_done if [ $TIMEOUT -gt 0 ]; then step_start "--idle (${TIMEOUT}s)" pullimap --idle "remote" & PID=$! trap "ptree_abort $PID" EXIT INT TERM timer=$(( $(date +%s) + TIMEOUT )) while [ $(date +%s) -le $timer ]; do n="$(shuf -n1 -i1-5)" for (( i=0; i < n; i++)); do sample_message | deliver -u "remote" -- -m "$MAILBOX" done s=$(shuf -n1 -i1-1500) [ $s -ge 1000 ] && s="$(printf "1.%03d" $((s-1000)))" || s="$(printf "0.%03d" $s)" sleep "$s" done sleep 5 ptree_abort $PID trap - EXIT INT TERM check step_done fi step_start "Purging" echo "purge-after = 0" >>"$XDG_CONFIG_HOME/pullimap/config" for ((i = 0; i < 32; i++)); do sample_message | deliver -u "remote" -- -m "$MAILBOX" done pullimap "remote" doveadm -u "remote" search --no-userdb-lookup mailbox "$MAILBOX" all >"$TMPDIR/messages" [ ! -s "$TMPDIR/messages" ] || error "messages left" step_done # vim: set filetype=bash : interimap-0.5.8/tests/rename-exists-db/ 0000775 0000000 0000000 00000000000 15003201720 0020013 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/rename-exists-db/local.conf 0000664 0000000 0000000 00000000046 15003201720 0021754 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/rename-exists-db/remote.conf 0000664 0000000 0000000 00000000051 15003201720 0022151 0 ustar 00root root 0000000 0000000 namespace inbox { separator = "\\" } interimap-0.5.8/tests/rename-exists-db/t 0000664 0000000 0000000 00000001213 15003201720 0020176 0 ustar 00root root 0000000 0000000 doveadm -u "local" mailbox create --no-userdb-lookup "root.from" "root.from.child" "t.o" doveadm -u "remote" mailbox create --no-userdb-lookup "root\\from" "root\\from\\child" "t\\o" interimap_init check_mailbox_list # delete a mailbox on both servers but leave it in the database, then try to use it as target for --rename doveadm -u "local" mailbox delete --no-userdb-lookup "t.o" doveadm -u "remote" mailbox delete --no-userdb-lookup "t\\o" ! interimap --rename "root.from" "t.o" || error grep -Fx 'database: ERROR: Mailbox t.o exists. Run `interimap --target=database --delete t.o` to delete.' <"$STDERR" || error # vim: set filetype=bash : interimap-0.5.8/tests/rename-exists-local/ 0000775 0000000 0000000 00000000000 15003201720 0020520 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/rename-exists-local/local.conf 0000664 0000000 0000000 00000000046 15003201720 0022461 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/rename-exists-local/remote.conf 0000664 0000000 0000000 00000000051 15003201720 0022656 0 ustar 00root root 0000000 0000000 namespace inbox { separator = "\\" } interimap-0.5.8/tests/rename-exists-local/t 0000664 0000000 0000000 00000001052 15003201720 0020704 0 ustar 00root root 0000000 0000000 doveadm -u "local" mailbox create --no-userdb-lookup "root.from" "root.from.child" "t.o" doveadm -u "remote" mailbox create --no-userdb-lookup "root\\from" "root\\from\\child" interimap_init check_mailbox_list # delete a mailbox on the remote server, then try to use it as target for --rename doveadm -u "remote" mailbox delete --no-userdb-lookup "t\\o" ! interimap --rename "root.from" "t.o" || error grep -Fx 'local: ERROR: Mailbox t.o exists. Run `interimap --target=local --delete t.o` to delete.' <"$STDERR" || error # vim: set filetype=bash : interimap-0.5.8/tests/rename-exists-remote/ 0000775 0000000 0000000 00000000000 15003201720 0020721 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/rename-exists-remote/local.conf 0000664 0000000 0000000 00000000046 15003201720 0022662 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/rename-exists-remote/remote.conf 0000664 0000000 0000000 00000000051 15003201720 0023057 0 ustar 00root root 0000000 0000000 namespace inbox { separator = "\\" } interimap-0.5.8/tests/rename-exists-remote/t 0000664 0000000 0000000 00000001061 15003201720 0021105 0 ustar 00root root 0000000 0000000 doveadm -u "local" mailbox create --no-userdb-lookup "root.from" "root.from.child" "t.o" doveadm -u "remote" mailbox create --no-userdb-lookup "root\\from" "root\\from\\child" "t\\o" interimap_init check_mailbox_list # delete a mailbox on the local server, then try to use it as target for --rename doveadm -u "local" mailbox delete --no-userdb-lookup "t.o" ! interimap --rename "root.from" "t.o" || error grep -Fx 'remote: ERROR: Mailbox t\o exists. Run `interimap --target=remote --delete t.o` to delete.' <"$STDERR" || remote # vim: set filetype=bash : interimap-0.5.8/tests/rename-inferiors/ 0000775 0000000 0000000 00000000000 15003201720 0020111 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/rename-inferiors/local.conf 0000664 0000000 0000000 00000000046 15003201720 0022052 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/rename-inferiors/remote.conf 0000664 0000000 0000000 00000000046 15003201720 0022253 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ^ } interimap-0.5.8/tests/rename-inferiors/t 0000664 0000000 0000000 00000007470 15003201720 0020307 0 ustar 00root root 0000000 0000000 doveadm -u "local" mailbox create --no-userdb-lookup "root.from" "root.from.child" "root.from.child2" "root.from.child.grandchild" doveadm -u "remote" mailbox create --no-userdb-lookup "root^sibbling" "root^sibbling^grandchild" "root2" for m in "root.from" "root.from.child" "root.from.child2" "root.from.child.grandchild" "INBOX"; do sample_message | deliver -u "local" -- -m "$m" done for m in "root^sibbling" "root^sibbling^grandchild" "root2" "INBOX"; do sample_message | deliver -u "remote" -- -m "$m" done interimap_init check_mailboxes_status "root.from" "root.from.child" "root.from.child2" "root.from.child.grandchild" \ "root.sibbling" "root.sibbling.grandchild" "root2" "INBOX" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/mailboxes.csv" <<-EOF .mode csv SELECT idx, hex(mailbox) FROM mailboxes ORDER BY idx EOF step_start "non-existent source (no-op)" interimap --rename "nonexistent" "root" || error "Renamed non-existent mailbox?" check_mailbox_list step_done step_start "\\NonExistent target (fail)" ! interimap --rename "root2" "root" || error "Didn't abort on ALREADYEXISTS" grep -E "^local: ERROR: Couldn't rename mailbox root2: NO \[ALREADYEXISTS\] " <"$STDERR" check_mailbox_list step_done # rename 'root.from' to 'from.root', including inferiors step_start "existing source with inferiors" interimap --rename "root.from" "from.root" grep -Fx 'local: Renamed mailbox root.from to from.root' <"$STDERR" grep -Fx 'remote: Renamed mailbox root^from to from^root' <"$STDERR" grep -Fx 'database: Renamed mailbox root.from to from.root' <"$STDERR" check_mailbox_list check_mailboxes_status "from.root" "from.root.child" "from.root.child2" "from.root.child.grandchild" \ "root.sibbling" "root.sibbling.grandchild" "root2" "INBOX" before="$(printf "%s\\0%s" "root" "from" | xxd -u -ps)" after="$(printf "%s\\0%s" "from" "root" | xxd -u -ps)" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/mailboxes2.csv" <<-EOF .mode csv SELECT idx, CASE WHEN mailbox = x'$after' OR hex(mailbox) LIKE '${after}00%' THEN '$before' || SUBSTR(hex(mailbox), $((${#after}+1))) ELSE hex(mailbox) END FROM mailboxes ORDER BY idx EOF diff -u --label="a/mailboxes.csv" --label="b/mailboxes.csv" \ "$TMPDIR/mailboxes.csv" "$TMPDIR/mailboxes2.csv" \ || error "Mailbox list differs" step_done # rename \NonExistent root and check that its children move step_start "\\NonExistent source with inferiors" interimap --rename "root" "newroot" grep -Fq 'local: Renamed mailbox root to newroot' <"$STDERR" grep -Fq 'remote: Renamed mailbox root to newroot' <"$STDERR" grep -Fq 'database: Renamed mailbox root to newroot' <"$STDERR" check_mailbox_list check_mailboxes_status "from.root" "from.root.child" "from.root.child2" "from.root.child.grandchild" \ "newroot.sibbling" "newroot.sibbling.grandchild" "root2" "INBOX" before2="$(printf "%s" "root" | xxd -u -ps)" after2="$(printf "%s" "newroot" | xxd -u -ps)" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/mailboxes3.csv" <<-EOF .mode csv SELECT idx, CASE WHEN mailbox = x'$after' OR hex(mailbox) LIKE '${after}00%' THEN '$before' || SUBSTR(hex(mailbox), $((${#after}+1))) WHEN hex(mailbox) LIKE '${after2}00%' THEN '$before2' || SUBSTR(hex(mailbox), $((${#after2}+1))) ELSE hex(mailbox) END FROM mailboxes ORDER BY idx EOF diff -u --label="a/mailboxes.csv" --label="b/mailboxes.csv" \ "$TMPDIR/mailboxes2.csv" "$TMPDIR/mailboxes3.csv" \ || error "Mailbox list differs" step_done interimap check_mailbox_list check_mailboxes_status "from.root" "from.root.child" "from.root.child2" "from.root.child.grandchild" \ "newroot.sibbling" "newroot.sibbling.grandchild" "root2" "INBOX" # vim: set filetype=bash : interimap-0.5.8/tests/rename-simple/ 0000775 0000000 0000000 00000000000 15003201720 0017402 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/rename-simple/t 0000664 0000000 0000000 00000003334 15003201720 0017573 0 ustar 00root root 0000000 0000000 doveadm -u "local" mailbox create --no-userdb-lookup "foo" sample_message | deliver -u "local" -- -m "INBOX" sample_message | deliver -u "remote" -- -m "INBOX" sample_message | deliver -u "remote" -- -m "foo" interimap_init sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/mailboxes.csv" <<-EOF .mode csv SELECT idx, hex(mailbox) FROM mailboxes ORDER BY idx EOF step_start "non-existent source (no-op)" interimap --rename "nonexistent" "bar" || error "Rename non-existent mailbox?" check_mailbox_list step_done step_start "existing target (fail)" ! interimap --rename "nonexistent" "foo" || error "Overwrote target?" grep -Fx "local: ERROR: Mailbox foo exists. Run \`interimap --target=local --delete foo\` to delete." <"$STDERR" || error check_mailbox_list step_done step_start "INBOX" interimap --rename "INBOX" "baz" || error check_mailbox_list step_done step_start "\\Noinferiors mailbox" interimap --rename "foo" "bar" || error check_mailbox_list sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/mailboxes2.csv" <<-EOF .mode csv SELECT idx, CASE WHEN mailbox = x'$(printf "baz" | xxd -u -ps)' THEN '$(printf "%s" "INBOX" | xxd -u -ps)' WHEN mailbox = x'$(printf "bar" | xxd -u -ps)' THEN '$(printf "%s" "foo" | xxd -u -ps)' ELSE hex(mailbox) END FROM mailboxes ORDER BY idx EOF diff -u --label="a/mailboxes.csv" --label="b/mailboxes.csv" \ "$TMPDIR/mailboxes.csv" "$TMPDIR/mailboxes2.csv" \ || error "Mailbox list differs" step_done interimap # recreated after renaming grep -Fx "database: Created mailbox INBOX" <"$STDERR" check_mailbox_list check_mailboxes_status "INBOX" "bar" "baz" # vim: set filetype=bash : interimap-0.5.8/tests/repair/ 0000775 0000000 0000000 00000000000 15003201720 0016126 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/repair/local.conf 0000664 0000000 0000000 00000000046 15003201720 0020067 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/repair/remote.conf 0000664 0000000 0000000 00000000046 15003201720 0020270 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ~ } interimap-0.5.8/tests/repair/t 0000664 0000000 0000000 00000011562 15003201720 0016321 0 ustar 00root root 0000000 0000000 # create some mailboxes and populate them doveadm -u "local" mailbox create --no-userdb-lookup "foo.bar" doveadm -u "remote" mailbox create --no-userdb-lookup "foo~bar" "baz" for ((i = 0; i < 8; i++)); do sample_message | deliver -u "local" -- -m "foo.bar" sample_message | deliver -u "remote" -- -m "foo~bar" done for ((i = 0; i < 64; i++)); do sample_message | deliver -u "remote" -- -m "baz" done interimap_init check_mailbox_list check_mailboxes_status "foo.bar" "baz" "INBOX" # make more changes (flag updates, new massages, deletions) sample_message | deliver -u "remote" -- -m "INBOX" doveadm -u "local" expunge --no-userdb-lookup mailbox "baz" 1:10 doveadm -u "remote" expunge --no-userdb-lookup mailbox "baz" "$(seq -s"," 1 2 32),$(seq -s"," 40 2 64)" doveadm -u "local" expunge --no-userdb-lookup mailbox "foo.bar" 2,3,5:7,10 doveadm -u "remote" expunge --no-userdb-lookup mailbox "foo~bar" 4,5,7,10 doveadm -u "local" flags add --no-userdb-lookup "\\Answered" mailbox "foo.bar" 2,3,5:7,10 doveadm -u "remote" flags add --no-userdb-lookup "\\Seen" mailbox "foo~bar" 4,5,7 # spoof HIGHESTMODSEQ value in the database to make it look that we recorded the new changes already spoof() { local k="$1" v m hex="$(printf "%s\\0%s" "foo" "bar" | xxd -ps)" shift while [ $# -gt 0 ]; do [ "$1" = "local" ] && m="foo.bar" || m="$(printf "%s" "foo.bar" | tr "." "~")" v="$(doveadm -u "$1" -f flow mailbox status --no-userdb-lookup "${k,,[A-Z]}" "$m" | sed 's/.*=//')" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" <<-EOF UPDATE \`$1\` SET $k = $v WHERE idx = (SELECT idx FROM mailboxes WHERE mailbox = x'$hex'); EOF shift done } spoof HIGHESTMODSEQ "local" "remote" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/dump.sql" <<-EOF .dump EOF doveadm -u "local" mailbox status --no-userdb-lookup "all" "foo.bar" >"$TMPDIR/foo-bar.status.local" doveadm -u "remote" mailbox status --no-userdb-lookup "all" "foo~bar" >"$TMPDIR/foo-bar.status.remote" # verify that without --repair interimap does nothing due to the spoofed HIGHESTMODSEQ values interimap "foo.bar" || error sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/dump2.sql" <<-EOF .dump EOF doveadm -u "local" mailbox status --no-userdb-lookup all "foo.bar" >"$TMPDIR/foo-bar.status2.local" doveadm -u "remote" mailbox status --no-userdb-lookup all "foo~bar" >"$TMPDIR/foo-bar.status2.remote" diff -u --label="a/dump.sql" --label="b/dump.sql" "$TMPDIR/dump.sql" "$TMPDIR/dump2.sql" diff -u --label="a/foo_bar.local" --label="a/foo_bar.local" "$TMPDIR/foo-bar.status.local" "$TMPDIR/foo-bar.status2.local" diff -u --label="a/foo_bar.remote" --label="a/foo_bar.remote" "$TMPDIR/foo-bar.status.remote" "$TMPDIR/foo-bar.status2.remote" # deliver more messages and spoof UIDNEXT *on one side only* sample_message | deliver -u "local" -- -m "foo.bar" sample_message | deliver -u "remote" -- -m "foo~bar" spoof UIDNEXT "local" spoof HIGHESTMODSEQ "local" "remote" # now repair interimap --repair "baz" "foo.bar" || error # 6 updates with \Answered (luid 4,8,11:13,16), 2 of which (luid 12,13) vanished from remote # 3 updates with \Seen (ruid 6,8,10), 1 of which (uid 10) vanished from remote # luid 16 <-> ruid 8 has both \Answered and \Seen xcgrep 5 '^WARNING: Missed flag update in foo\.bar for ' <"$STDERR" xcgrep 5 '^WARNING: Conflicting flag update in foo\.bar ' <"$STDERR" # luid 2 <-> ruid 10 xcgrep 1 -E '^WARNING: Pair \(lUID,rUID\) = \([0-9]+,[0-9]+\) vanished from foo\.bar\. Repairing\.$' <"$STDERR" # 6-1 (luid 2 <-> ruid 10 is gone from both) xcgrep 5 -E '^local\(foo\.bar\): WARNING: UID [0-9]+ disappeared. Redownloading remote UID [0-9]+\.$' <"$STDERR" # 6-1 (luid 2 <-> ruid 10 is gone from both) xcgrep 3 -E '^remote\(foo~bar\): WARNING: UID [0-9]+ disappeared. Redownloading local UID [0-9]+\.$' <"$STDERR" grep -E '^local\(baz\): Removed 24 UID\(s\) ' <"$STDERR" || error grep -E '^remote\(baz\): Removed 5 UID\(s\) ' <"$STDERR" || error # hardcoding UIDs here is not very robust... grep -E '^local\(foo\.bar\): Updated flags \(\\Answered \\Seen\) for UID 16$' <"$STDERR" || error grep -E '^local\(foo\.bar\): Updated flags \(\\Seen\) for UID 14$' <"$STDERR" || error grep -E '^remote\(foo~bar\): Updated flags \(\\Answered \\Seen\) for UID 8$' <"$STDERR" || error grep -E '^remote\(foo~bar\): Updated flags \(\\Answered\) for UID 3,12,16$' <"$STDERR" || error # luid 17 xcgrep 1 -E '^remote\(foo~bar\): WARNING: No match for modified local UID [0-9]+. Redownloading\.' <"$STDERR" grep -E '^local\(foo\.bar\): Added 5 UID\(s\) ' <"$STDERR" || error grep -E '^remote\(foo~bar\): Added 4 UID\(s\) ' <"$STDERR" || error grep -E '^local\(foo\.bar\): Added 1 UID\(s\) ' <"$STDERR" || error # the new message check_mailbox_list check_mailboxes_status "baz" "foo.bar" interimap || error check_mailboxes_status "baz" "foo.bar" "INBOX" # vim: set filetype=bash : interimap-0.5.8/tests/resume/ 0000775 0000000 0000000 00000000000 15003201720 0016144 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/resume/local.conf 0000664 0000000 0000000 00000000046 15003201720 0020105 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/resume/remote.conf 0000664 0000000 0000000 00000000046 15003201720 0020306 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ~ } interimap-0.5.8/tests/resume/t 0000664 0000000 0000000 00000007245 15003201720 0016342 0 ustar 00root root 0000000 0000000 # create and populate a bunch of mailboxes doveadm -u "local" mailbox create --no-userdb-lookup "foo" "foo.bar" "baz" for ((i = 0; i < 8; i++)); do sample_message | deliver -u "local" -- -m "foo" sample_message | deliver -u "local" -- -m "foo.bar" sample_message | deliver -u "local" -- -m "INBOX" done interimap_init check_mailbox_list check_mailboxes_status "foo" "foo.bar" "baz" "INBOX" # spoof UIDNEXT in the database set_uidnext() { local imap="$1" mailbox="$2" uidnext="$3" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" <<-EOF UPDATE $imap SET UIDNEXT = $uidnext WHERE idx = ( SELECT idx FROM mailboxes WHERE mailbox = x'$mailbox' ); EOF } # spoof "foo"'s UIDVALIDITY and UIDNEXT values uidvalidity="$(doveadm -u "local" -f flow mailbox status --no-userdb-lookup uidvalidity "foo" | sed 's/.*=//')" [ $uidvalidity -eq 4294967295 ] && uidvalidity2=1 || uidvalidity2=$((uidvalidity+1)) doveadm -u "local" mailbox update --no-userdb-lookup --uid-validity "$uidvalidity2" "foo" set_uidnext "local" "$(printf "%s" "foo" | xxd -ps)" 1 # verify that interimap chokes on the UIDVALIDITY change without doing any changes sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/dump.sql" <<-EOF .dump EOF doveadm -u "local" mailbox status --no-userdb-lookup "all" "foo" >"$TMPDIR/foo.local" doveadm -u "remote" mailbox status --no-userdb-lookup "all" "foo" >"$TMPDIR/foo.remote" ! interimap || error grep -Fx "Resuming interrupted sync for foo" <"$STDERR" grep -Fx "local(foo): ERROR: UIDVALIDITY changed! ($uidvalidity2 != $uidvalidity) Need to invalidate the UID cache for foo." <"$STDERR" sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/dump2.sql" <<-EOF .dump EOF doveadm -u "local" mailbox status --no-userdb-lookup "all" "foo" >"$TMPDIR/foo.local2" doveadm -u "remote" mailbox status --no-userdb-lookup "all" "foo" >"$TMPDIR/foo.remote2" diff -u --label="a/dump.sql" --label="b/dump.sql" "$TMPDIR/dump2.sql" "$TMPDIR/dump.sql" diff -u --label="a/foo.local" --label="b/foo.remote" "$TMPDIR/foo.local" "$TMPDIR/foo.local2" diff -u --label="a/foo.local" --label="b/foo.remote" "$TMPDIR/foo.remote" "$TMPDIR/foo.remote2" # spoof UIDNEXT values for INBOX (local+remote) and foo.bar (remote) set_uidnext "local" "$(printf "%s" "INBOX" | xxd -ps)" 2 set_uidnext "remote" "$(printf "%s" "INBOX" | xxd -ps)" 2 set_uidnext "remote" "$(printf "%s\\0%s" "foo" "bar" | xxd -ps)" 0 # set some flags and remove some messages for UIDs >2 doveadm -u "local" flags add --no-userdb-lookup "\\Seen" mailbox "INBOX" 6,7 doveadm -u "remote" flags add --no-userdb-lookup "\\Deleted" mailbox "INBOX" 6,8 doveadm -u "local" expunge --no-userdb-lookup mailbox "INBOX" 4,5 doveadm -u "remote" expunge --no-userdb-lookup mailbox "INBOX" 3,4 doveadm -u "remote" expunge --no-userdb-lookup mailbox "foo~bar" 5 # add new messages sample_message | deliver -u "local" -- -m "foo.bar" sample_message | deliver -u "remote" -- -m "foo~bar" sample_message | deliver -u "local" -- -m "baz" interimap "foo.bar" "InBoX" "baz" # ignore "foo" grep -Fx "Resuming interrupted sync for foo.bar" <"$STDERR" grep -Fx "Resuming interrupted sync for INBOX" <"$STDERR" check_mailbox_list check_mailboxes_status "foo.bar" "INBOX" "baz" # ignore "foo" # count entries in the mapping table sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/count" <<-EOF SELECT COUNT(*) FROM mapping NATURAL JOIN mailboxes WHERE mailbox != x'$(printf "%s" "foo" | xxd -ps)' GROUP BY idx ORDER BY mailbox; EOF # count messages: # INBOX: 8-2-1 = 5 # baz: 1 # foo.bar: 8-1+1+1 = 9 diff -u --label="a/count" --label="b/count" "$TMPDIR/count" - <<-EOF 5 1 9 EOF # vim: set filetype=bash : interimap-0.5.8/tests/run 0000775 0000000 0000000 00000040752 15003201720 0015406 0 ustar 00root root 0000000 0000000 #!/bin/bash #---------------------------------------------------------------------- # Test suite for InterIMAP # Copyright © 2019 Guilhem Moulin # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . #---------------------------------------------------------------------- set -ue PATH=/usr/bin:/bin export PATH if [ $# -eq 0 ] || [ $# -gt 2 ]; then printf "Usage: %s TESTFILE [TESTNAME]\\n" "$0" >&2 exit 1 fi BASEDIR="$(dirname -- "$0")" TESTDIR="$BASEDIR/$1" TESTNAME="${2-$1}" if [ ! -d "$TESTDIR" ]; then printf "ERROR: Not a directory: %s\\n" "$TESTDIR" >&2 exit 1 fi # cleanup environment unset OPENSSL_CONF SSL_CERT_FILE SSL_CERT_DIR if [ -z "${INTERIMAP_PATH+x}" ]; then INTERIMAP_PATH="./" elif [ -n "$INTERIMAP_PATH" ]; then INTERIMAP_PATH="${INTERIMAP_PATH%/}/" fi ROOTDIR="$(mktemp --tmpdir="${TMPDIR:-/dev/shm}" --directory "$1.XXXXXXXXXX")" declare -a DOVECOT_SERVER=() trap cleanup EXIT INT TERM cleanup() { local pid c for c in "${DOVECOT_SERVER[@]}"; do if [ ! -f "$c" ] || ! env -i PATH="/usr/bin:/bin" doveadm -c "$c" stop; then pid="$(< "${c%/*}/run/master.pid")" kill -TERM "$pid" || printf "kill(1) exited with status %d\\n" "$?" >&2 fi done rm -rf -- "$ROOTDIR" } _STDOUT="$ROOTDIR/stdout" _STDERR="$ROOTDIR/stderr" TMPDIR="$ROOTDIR/tmp" STDERR="$(mktemp --tmpdir="$ROOTDIR" "stderr.XXXXXXXXXX")" mkdir -- "$TMPDIR" "$ROOTDIR/home" declare -a REMOTES=() # Set environment for the given user environ_set() { local user="$1" home eval home="\$HOME_$user" ENVIRON=( PATH="$PATH" USER="$user" HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_DATA_HOME="$home/.local/share" ) } # Prepare the test harness prepare() { declare -a ENVIRON=() local src cfg target u home n proto if [ -f "$TESTDIR/remotes" ] || [ -L "$TESTDIR/remotes" ]; then for cfg in $(seq 1 "$(< "$TESTDIR/remotes")"); do REMOTES+=( "remote$cfg" ) done else REMOTES+=( "remote" ) fi # copy dovecot config for u in "local" "${REMOTES[@]}"; do home="$ROOTDIR/$u/home" export "HOME_$u"="$home" environ_set "$u" mkdir -pm0700 -- "$home/.dovecot" cat >"$home/.dovecot/config" <<-EOF dovecot_config_version = 2.4.0 dovecot_storage_version = 2.4.0 log_path = $HOME_local/mail.log mail_home = $home mail_driver = sdbox mail_path = ~/inbox mailbox_list_index = yes ssl = no listen = 127.0.0.1, 127.0.1.1, ::1 namespace inbox { inbox = yes } EOF if [ -f "$TESTDIR/$u.conf" ] || [ -L "$TESTDIR/$u.conf" ]; then cat >>"$home/.dovecot/config" <"$TESTDIR/$u.conf" fi cp -aT -- "$BASEDIR/config/dovecot" "$home/.dovecot/conf.d" cp -at "$home/.dovecot/conf.d" -- "$BASEDIR/certs/ca.crt" "$BASEDIR/certs"/dovecot.* proto="$(env -i "${ENVIRON[@]}" doveconf -c "$home/.dovecot/config" -h protocols)" if [ -n "$proto" ]; then cat >>"$home/.dovecot/config" <<-EOF # https://doc.dovecot.org/latest/core/config/rootless.html#rootless-installation dovecot_config_version = 2.4.0 dovecot_storage_version = 2.4.0 base_dir = $home/.dovecot/run default_internal_user = $(id -un) default_internal_group = $(id -gn) default_login_user = $(id -un) service anvil { chroot = } service imap-login { chroot = } service stats { chroot = } passdb passwd-file { driver = passwd-file default_password_scheme = plain passwd_file_path = $home/.dovecot/users } userdb passwd-file { driver = passwd-file passwd_file_path = $home/.dovecot/users } EOF env -i PATH="/usr/bin:/bin" /usr/sbin/dovecot -c "$home/.dovecot/config" DOVECOT_SERVER+=( "$home/.dovecot/config" ) printf "%s:%s:::::\\n" "$u" "$(xxd -l16 -p "$home/.dovecot/users" fi mkdir -pm0755 -- "$home/.local/bin" cat >"$home/.local/bin/doveadm" <<-EOF #!/bin/sh exec env -i ${ENVIRON[@]@Q} \\ doveadm -c ${home@Q}/.dovecot/config "\$@" EOF chmod +x -- "$home/.local/bin/doveadm" done # copy interimap and pullimap configuration mkdir -pm0700 -- "$HOME_local/.local/share/interimap" "$HOME_local/.local/share/pullimap" mkdir -pm0700 -- "$HOME_local/.config/interimap" "$HOME_local/.config/pullimap" echo "deliver-rcpt = local" >>"$HOME_local/.config/pullimap/config" for u in "${REMOTES[@]}"; do n="${u#remote}" eval home="\$HOME_$u" cat >>"$HOME_local/.config/interimap/config$n" <<-EOF database = $u.db #logfile = $HOME_local/interimap$n.log EOF if [ -f "$TESTDIR/interimap$n.conf" ] || [ -L "$TESTDIR/interimap$n.conf" ]; then cat <"$TESTDIR/interimap$n.conf" >>"$HOME_local/.config/interimap/config$n" fi if [ -f "$TESTDIR/pullimap.conf" ] || [ -L "$TESTDIR/pullimap.conf" ]; then cat <"$TESTDIR/pullimap.conf" >>"$HOME_local/.config/pullimap/config" fi cat >>"$HOME_local/.config/interimap/config$n" <<-EOF [local] type = tunnel command = exec ${HOME_local@Q}/.local/bin/doveadm exec imap null-stderr = NO EOF if [ -f "$TESTDIR/interimap$n.local" ] || [ -L "$TESTDIR/interimap$n.local" ]; then cat <"$TESTDIR/interimap$n.local" >>"$HOME_local/.config/interimap/config$n" fi if [ -s "$home/.dovecot/users" ]; then cat <<-EOF username = $u password = $(awk -F: -vu="$u" '$1 == u {print $2}' <"$home/.dovecot/users") EOF else cat <<-EOF type = tunnel command = exec ${home@Q}/.local/bin/doveadm exec imap null-stderr = NO EOF fi >"$HOME_local/.$u.conf" if [ -f "$TESTDIR/interimap$n.remote" ] || [ -L "$TESTDIR/interimap$n.remote" ]; then cat <"$TESTDIR/interimap$n.remote" >>"$HOME_local/.$u.conf" fi { printf "\\n[remote]\\n"; cat <"$HOME_local/.$u.conf"; } >>"$HOME_local/.config/interimap/config$n" { printf "\\n[%s]\\n" "$u"; cat <"$HOME_local/.$u.conf"; } >>"$HOME_local/.config/pullimap/config" done } prepare # Wrappers for interimap(1) and doveadm(1) interimap() { _interimap_cmd "interimap" "$@"; } pullimap() { _interimap_cmd "pullimap" "$@"; } _interimap_cmd() { declare -a ENVIRON=() args=() local script="$1" rv=0 shift environ_set "local" [ -z "${OPENSSL_CONF+x}" ] || ENVIRON+=( OPENSSL_CONF="$OPENSSL_CONF" ) [ -z "${SSL_CERT_FILE+x}" ] || ENVIRON+=( SSL_CERT_FILE="$SSL_CERT_FILE" ) [ -z "${SSL_CERT_DIR+x}" ] || ENVIRON+=( SSL_CERT_DIR="$SSL_CERT_DIR" ) [ -z "${INTERIMAP_I:+x}" ] || args+=( perl -I"$INTERIMAP_I" -T ) args+=( "$INTERIMAP_PATH$script" "$@" ) #printf "I: Running \`%s\`\\n" "${args[*]}" >&3 env -i "${ENVIRON[@]}" "${args[@]}" 2>"$STDERR" || rv=$? cat <"$STDERR" >&2 return $rv } interimap_init() { local u="${1-remote}" local db="$XDG_DATA_HOME/interimap/$u.db" st local cfg="config${u#remote}" test \! -e "$db" || error "Database already exists" 1 interimap --config "$cfg" || error "Couldn't initialize interimap" 1 test -f "$db" || error "Database is still missing" 1 grep -Fx "Creating new schema in database file $db" <"$STDERR" || error "DB wasn't created" 1 if ! st="$(stat -c"%#a" -- "$db")" || [ "$st" != "0600" ]; then error "$db has mode $st != 0600" 1 fi } doveadm() { if [ $# -le 2 ] || [ "$1" != "-u" ]; then echo "Usage: doveadm -u USER ..." >&2 exit 1 fi local u="$2" home eval home="\$HOME_$u" shift 2 "$home/.local/bin/doveadm" "$@" } sqlite3() { command sqlite3 -init /dev/null "$@" } # Sample (random) message sample_message() { local date="$(date +"%s.%N")" cat <<-EOF From: <$(xxd -ps -l6 /dev/urandom)@example.net> To: Date: $(date -R -d@"$date") Message-ID: <$date@example.net> EOF local len="$(shuf -i1-4096 -n1)" xxd -ps -c30 -l"$len" /dev/urandom # 3 to 8329 bytes } # Wrapper for dovecot-lda(1) deliver() { declare -a argv while [ $# -gt 0 ] && [ "$1" != "--" ]; do argv+=( "$1" ) shift done if [ $# -gt 0 ] && [ "$1" = "--" ]; then shift fi doveadm "${argv[@]}" exec dovecot-lda -e "$@" } # Dump test results dump_test_result() { local below=">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>" local above="<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<" local src u home declare -a ENVIRON=() for u in "local" "${REMOTES[@]}"; do environ_set "$u" eval home="\$HOME_$u" printf "%s dovecot configuration:\\n%s\\n" "$u" "$below" env -i "${ENVIRON[@]}" doveconf -c "$home/.dovecot/config" -n printf "%s\\n\\n" "$above" done for u in "${REMOTES[@]}"; do printf "interimap configuration (local <-> $u):\\n%s\\n" "$below" cat <"$HOME_local/.config/interimap/config${u#remote}" printf "%s\\n\\n" "$above" done printf "mail.log:\\n%s\\n" "$below" cat -- "$HOME_local/mail.log" 2>/dev/null || true printf "%s\\n\\n" "$above" printf "standard output:\\n%s\\n" "$below" cat <"$_STDOUT" printf "%s\\n\\n" "$above" printf "standard error:\\n%s\\n" "$below" cat <"$_STDERR" printf "%s\\n\\n" "$above" } # Check mailbox consistency between the local/remote server and interimap's database check_mailbox_status() { local mailbox="$1" lns="inbox" lsep lprefix rns="inbox" rsep rprefix lsep="$(doveconf -c "$HOME_local/.dovecot/config" -h "namespace/$lns/separator")" lprefix="$(doveconf -c "$HOME_local/.dovecot/config" -h "namespace/$lns/prefix")" rsep="$(doveconf -c "$HOME_remote/.dovecot/config" -h "namespace/$lns/separator")" rprefix="$(doveconf -c "$HOME_remote/.dovecot/config" -h "namespace/$lns/prefix")" local blob="x'$(printf "%s" "$mailbox" | tr "$lsep" "\\0" | xxd -c256 -ps)'" local rmailbox="$(printf "%s" "$mailbox" | tr "$lsep" "$rsep")" check_mailbox_status2 "$blob" "$lprefix$mailbox" "remote" "$rprefix$rmailbox" } check_mailbox_status2() { local blob="$1" lmailbox="$2" u="$3" rmailbox="$4" local lUIDVALIDITY lUIDNEXT lHIGHESTMODSEQ rUIDVALIDITY rUIDNEXT rHIGHESTMODSEQ read lUIDVALIDITY lUIDNEXT lHIGHESTMODSEQ rUIDVALIDITY rUIDNEXT rHIGHESTMODSEQ < <( sqlite3 "$XDG_DATA_HOME/interimap/$u.db" <<-EOF .mode csv .separator " " "\\n" SELECT l.UIDVALIDITY, l.UIDNEXT, l.HIGHESTMODSEQ, r.UIDVALIDITY, r.UIDNEXT, r.HIGHESTMODSEQ FROM mailboxes m JOIN local l ON m.idx = l.idx JOIN remote r ON m.idx = r.idx WHERE mailbox = $blob EOF ) lHIGHESTMODSEQ="$(printf "%llu" "$lHIGHESTMODSEQ")" rHIGHESTMODSEQ="$(printf "%llu" "$rHIGHESTMODSEQ")" local MESSAGES read MESSAGES < <( sqlite3 "$XDG_DATA_HOME/interimap/$u.db" <<-EOF .mode csv .separator " " "\\n" SELECT COUNT(*) FROM mailboxes a JOIN mapping b ON a.idx = b.idx WHERE mailbox = $blob EOF ) check_mailbox_status_values "local" "$lmailbox" $lUIDVALIDITY $lUIDNEXT $lHIGHESTMODSEQ $MESSAGES check_mailbox_status_values "$u" "$rmailbox" $rUIDVALIDITY $rUIDNEXT $rHIGHESTMODSEQ $MESSAGES local a b a="$(doveadm -u "local" -f "flow" mailbox status --no-userdb-lookup "messages unseen vsize" -- "$lmailbox" | \ sed -nr '/.*\s+(\w+=[0-9]+\s+\w+=[0-9]+\s+\w+=[0-9]+)$/ {s//\1/p;q}')" b="$(doveadm -u "$u" -f "flow" mailbox status --no-userdb-lookup "messages unseen vsize" -- "$rmailbox" | \ sed -nr '/.*\s+(\w+=[0-9]+\s+\w+=[0-9]+\s+\w+=[0-9]+)$/ {s//\1/p;q}')" if [ "$a" != "$b" ]; then echo "Mailbox $lmailbox status differs: \"$a\" != \"$b\"" >&2 exit 1 fi } check_mailbox_status_values() { local user="$1" mailbox="$2" UIDVALIDITY="$3" UIDNEXT="$4" HIGHESTMODSEQ="$5" MESSAGES="$6" x xs v k xs="$(doveadm -u "$user" -f "flow" mailbox status --no-userdb-lookup "uidvalidity uidnext highestmodseq messages" -- "$mailbox" | \ sed -nr '/.*\s+(\w+=[0-9]+\s+\w+=[0-9]+\s+\w+=[0-9]+\s+\w+=[0-9]+)$/ {s//\1/p;q}')" [ -n "$xs" ] || exit 1 for x in $xs; do k="${x%%=*}" case "${k^^[a-z]}" in UIDVALIDITY) v="$UIDVALIDITY";; UIDNEXT) v="$UIDNEXT";; HIGHESTMODSEQ) v="$HIGHESTMODSEQ";; MESSAGES) v="$MESSAGES";; *) echo "Uh? $x" >&2; exit 1 esac if [ "${x#*=}" != "$v" ]; then echo "$user($mailbox): ${k^^[a-z]} doesn't match! ${x#*=} != $v" >&2 exit 1 fi done } check_mailboxes_status() { local mailbox for mailbox in "$@"; do check_mailbox_status "$mailbox" done } # Check mailbox list constency between the local and remote servers check_mailbox_list() { local m i lns="inbox" lsep lprefix rns="inbox" rsep rprefix sub= lsep="$(doveconf -c "$HOME_local/.dovecot/config" -h "namespace/$lns/separator")" lprefix="$(doveconf -c "$HOME_local/.dovecot/config" -h "namespace/$lns/prefix")" rsep="$(doveconf -c "$HOME_remote/.dovecot/config" -h "namespace/$lns/separator")" rprefix="$(doveconf -c "$HOME_remote/.dovecot/config" -h "namespace/$lns/prefix")" if [ $# -gt 0 ] && [ "$1" = "-s" ]; then sub="-s" shift fi declare -a lmailboxes=() rmailboxes=() if [ $# -eq 0 ]; then lmailboxes=( "${lprefix}*" ) rmailboxes=( "${rprefix}*" ) else for m in "$@"; do lmailboxes+=( "$lprefix$m" ) rmailboxes+=( "$rprefix${m//"$lsep"/"$rsep"}" ) done fi mapfile -t lmailboxes < <( doveadm -u "local" mailbox list --no-userdb-lookup $sub -- "${lmailboxes[@]}" ) for ((i = 0; i < ${#lmailboxes[@]}; i++)); do lmailboxes[i]="${lmailboxes[i]#"$lprefix"}" done mapfile -t rmailboxes < <( doveadm -u "remote" mailbox list --no-userdb-lookup $sub -- "${rmailboxes[@]}" ) for ((i = 0; i < ${#rmailboxes[@]}; i++)); do rmailboxes[i]="${rmailboxes[i]#"$rprefix"}" rmailboxes[i]="${rmailboxes[i]//"$rsep"/"$lsep"}" done local IFS=$'\n' diff -u --label="local/mailboxes" --label="remote/mailboxes" \ <( printf "%s" "${lmailboxes[*]}" | sort ) <( printf "%s" "${rmailboxes[*]}" | sort ) } # Wrapper for `grep -c` xcgrep() { local m="$1" n shift if ! n="$(grep -c "$@")" || [ $m -ne $n ]; then error "\`grep -c ${*@Q}\` failed ($m != $n)" 1 fi } error() { local err="${1+": $1"}" i=${2-0} printf "ERROR$err on file %s line %d\\n" "${BASH_SOURCE[i+1]}" ${BASH_LINENO[i]} >&2 exit 1 } ptree_abort() { local pid for pid in "$@"; do # kill a process and its children pkill -TERM -P "$pid" || printf "pkill(1) exited with status %d\\n" "$?" >&2 kill -TERM "$pid" || printf "kill(1) exited with status %d\\n" "$?" >&2 done wait } step_start() { printf "%s%s..." "${INDENT-}" "$1" >&3; } step_done() { passed >&3; } failed() { [ -t 1 ] && printf " \\x1B[1;31mFAILED\\x1B[0m\\n" || echo " FAILED" } passed() { [ -t 1 ] && printf " \\x1B[1;32mPASSED\\x1B[0m\\n" || echo " PASSED" } # Run test in a sub-shell declare -a ENVIRON=() environ_set "local" export TMPDIR TESTDIR INTERIMAP_PATH INTERIMAP_I STDERR "${ENVIRON[@]}" export -f environ_set doveadm interimap interimap_init pullimap _interimap_cmd export -f sqlite3 sample_message deliver ptree_abort step_start step_done passed export -f check_mailbox_status check_mailbox_status_values check_mailbox_status2 export -f check_mailboxes_status check_mailbox_list xcgrep error [ "$TESTNAME" = "..." ] || printf "%s%s..." "${INDENT-}" "$TESTNAME" if ! bash -ue "$TESTDIR/t" 3>&1 >"$_STDOUT" 2>"$_STDERR"; then failed [ "${QUIET-n}" = "y" ] || dump_test_result exit 1 else [ "$TESTNAME" = "..." ] || passed if grep -Paq "\\x00" -- "$_STDOUT" "$_STDERR"; then printf "\\tWARN: binary output (outstanding \\0)!\\n" fi exit 0 fi interimap-0.5.8/tests/run-all 0000775 0000000 0000000 00000003611 15003201720 0016145 0 ustar 00root root 0000000 0000000 #!/bin/bash #---------------------------------------------------------------------- # Test suite for InterIMAP # Copyright © 2019 Guilhem Moulin # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . #---------------------------------------------------------------------- set -ue PATH=/usr/bin:/bin export PATH BASEDIR="$(dirname -- "$0")" RUN="$BASEDIR/run" list="$1" failed=0 while IFS="" read -r x; do if [[ "$x" =~ ^([[:blank:]]*)([^[:blank:]#]+)[[:blank:]]+(.*)$ ]]; then indent="${BASH_REMATCH[1]}" t="${BASH_REMATCH[2]}" desc="${BASH_REMATCH[3]}" if [ "$t" = "." ]; then printf "%s%s:\\n" "$indent" "$desc" continue elif [ "$t" = "..." ]; then t="$desc" desc="..." fi elif [[ "$x" =~ ^([[:blank:]]*)([^[:blank:]#]+)$ ]]; then indent="${BASH_REMATCH[1]}" t="${BASH_REMATCH[2]}" unset desc else continue fi if [ ! -d "$BASEDIR/$t" ]; then printf "WARN: %s does doesn't exist, skipping\\n" "$t" >&2 continue fi INDENT="$indent" "$RUN" "$t" ${desc+"$desc"} || failed=$(( failed+1 )) done <"$BASEDIR/$list" if [ $failed -eq 0 ]; then printf "All tests passed.\\n" exit 0 else printf "%d test(s) failed.\\n" $failed exit 1 fi interimap-0.5.8/tests/split-set/ 0000775 0000000 0000000 00000000000 15003201720 0016570 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/split-set/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0030412 2../auth-sasl-plain/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/split-set/remote.conf 0000777 0000000 0000000 00000000000 15003201720 0026304 2../auth-sasl-plain/remote.conf ustar 00root root 0000000 0000000 interimap-0.5.8/tests/split-set/t 0000664 0000000 0000000 00000002742 15003201720 0016763 0 ustar 00root root 0000000 0000000 N=2048 # XXX with COMPRESS=DEFLATE dovecot-imapd 2.3.4 hangs when the command # line exceeds 'imap_max_line_length' (or 8192, whichever is smaller) # bytes, instead of returning a tagged BAD response. # https://dovecot.org/pipermail/dovecot/2019-November/117522.html # set UIDNEXT to 10^9 so all uids are 10 chars long, otherwise we'd need # to add many more messages to obtain large sets doveadm -u "local" mailbox update --no-userdb-lookup --min-next-uid 1000000000 "INBOX" doveadm -u "remote" mailbox update --no-userdb-lookup --min-next-uid 1000000000 "INBOX" for ((i = 0; i < N; i++)); do u="$(shuf -n1 -e "local" "remote")" sample_message | deliver -u "$u" done interimap_init check_mailbox_status "INBOX" # mark every other message as \Seen on the local server for ((i = 0; i < N; i+=2)); do doveadm -u "local" flags add --no-userdb-lookup "\\Seen" mailbox "INBOX" $((N-i)) done # send the changes to the remote; this results into an UID STORE set # representation of size 11*N/2-1, which exceeds $imap_max_line_length interimap check_mailbox_status "INBOX" # now expunge every other message on the remote server; this results # into large UID STORE and UID EXPUNGE set representation for ((i = 0; i < N; i+=2)); do doveadm -u "local" expunge --no-userdb-lookup mailbox "INBOX" $((N-i)) # add some more messages u="$(shuf -n1 -e "local" "remote")" sample_message | deliver -u "$u" done interimap || error check_mailbox_status "INBOX" # vim: set filetype=bash : interimap-0.5.8/tests/starttls-injection/ 0000775 0000000 0000000 00000000000 15003201720 0020504 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/starttls-injection/imapd 0000775 0000000 0000000 00000005375 15003201720 0021536 0 ustar 00root root 0000000 0000000 #!/usr/bin/perl -T use warnings; use strict; use Errno qw/EINTR/; use Net::SSLeay qw/die_now/; use Socket qw/INADDR_LOOPBACK AF_INET SOCK_STREAM pack_sockaddr_in SOL_SOCKET SO_REUSEADDR SHUT_RDWR/; BEGIN { Net::SSLeay::load_error_strings(); Net::SSLeay::SSLeay_add_ssl_algorithms(); Net::SSLeay::randomize(); } socket(my $S, AF_INET, SOCK_STREAM, 0) or die; setsockopt($S, SOL_SOCKET, SO_REUSEADDR, pack("l", 1)) or die; bind($S, pack_sockaddr_in(10143, INADDR_LOOPBACK)) or die "bind: $!\n"; listen($S, 1) or die "listen: $!"; my $CONFDIR = $ENV{HOME} =~ /\A(\p{Print}+)\z/ ? "$1/.dovecot/conf.d" : die; my $CTX = Net::SSLeay::CTX_new() or die_now("SSL_CTX_new()"); Net::SSLeay::CTX_set_mode($CTX, Net::SSLeay::MODE_ENABLE_PARTIAL_WRITE() | Net::SSLeay::MODE_ACCEPT_MOVING_WRITE_BUFFER() | Net::SSLeay::MODE_AUTO_RETRY() | # don't fail SSL_read on renegotiation Net::SSLeay::MODE_RELEASE_BUFFERS() ); Net::SSLeay::CTX_use_PrivateKey_file($CTX, "$CONFDIR/dovecot.rsa.key", &Net::SSLeay::FILETYPE_PEM) or die_now("Can't load private key: $!"); Net::SSLeay::CTX_use_certificate_file($CTX, "$CONFDIR/dovecot.rsa.crt", &Net::SSLeay::FILETYPE_PEM) or die_now("Can't load certificate: $!"); while (1) { my $sockaddr = accept(my $conn, $S) or do { next if $! == EINTR; die "accept: $!"; }; $conn->printflush("* OK IMAP4rev1 Server\r\n"); $conn->getline() =~ /\A(\S+) CAPABILITY\r\n\z/ or die; $conn->printflush("* CAPABILITY IMAP4rev1 STARTTLS\r\n"); $conn->printflush("$1 OK CAPABILITY completed\r\n"); $conn->getline() =~ /\A(\S+) STARTTLS\r\n\z/ or die; # These responses preceed the TLS handshake hence are not authenticated! $conn->print("$1 OK Begin TLS\r\n"); $conn->print("* CAPABILITY IMAP4rev1 LOGINDISABLED X-injected\r\n"); # Note: tag format must match Net::IMAP::InterIMAP->_cmd_init() $conn->printf("%06d OK CAPABILITY injected\r\n", $1+1); $conn->flush(); my $ssl = Net::SSLeay::new($CTX) or die_now("SSL_new()"); die_now("SSL_set_fd()") unless Net::SSLeay::set_fd($ssl, $conn) == 1; die_now("SSL_accept()") unless Net::SSLeay::accept($ssl); Net::SSLeay::ssl_read_CRLF($ssl) =~ /\A(\S+) CAPABILITY\r\n\z/ or die_now("SSL_read()"); Net::SSLeay::ssl_write_CRLF($ssl, "* CAPABILITY IMAP4rev1 AUTH=LOGIN\r\n$1 OK CAPABILITY completed"); Net::SSLeay::ssl_read_CRLF($ssl) =~ /\A(\S+) LOGIN .*\r\n\z/ or die_now("SSL_read()"); Net::SSLeay::ssl_write_CRLF($ssl, "$1 OK [CAPABILITY IMAP4rev1] LOGIN completed"); Net::SSLeay::free($ssl); close($conn); last; } END { Net::SSLeay::CTX_free($CTX) if defined $CTX; if (defined $S) { shutdown($S, SHUT_RDWR) or warn "shutdown: $!"; close($S) or print STDERR "close: $!\n"; } } interimap-0.5.8/tests/starttls-injection/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0031204 2../starttls/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/starttls-injection/remote.conf 0000664 0000000 0000000 00000000144 15003201720 0022645 0 ustar 00root root 0000000 0000000 protocols { imap = yes } service imap-login { inet_listener imap { port = 0 } } interimap-0.5.8/tests/starttls-injection/t 0000664 0000000 0000000 00000001520 15003201720 0020670 0 ustar 00root root 0000000 0000000 # Test unauthenticated response injection after the STARTTLS response # For background see https://gitlab.com/muttmua/mutt/-/issues/248 env -i USER="remote" HOME="$HOME_remote" "$TESTDIR/imapd" & PID=$! trap "ptree_abort $PID" EXIT INT TERM ! interimap --debug || error # Make sure we show a warning but ignore ignore (unauthenticated) injected responses ! grep -E 'remote: S: .*[ -]injected$' <"$STDERR" || error "unauthenticated response injection" grep -Fx 'remote: WARNING: Truncating non-empty output buffer (unauthenticated response injection?)' <"$STDERR" || error ! grep -Fx 'remote: ERROR: Logins are disabled.' <"$STDERR" || error "injected capability wasn't ignored" grep -Fx 'remote: ERROR: Server did not advertise ENABLE (RFC 5161) capability.' <"$STDERR" || error "injected capability wasn't ignored" # vim: set filetype=bash : interimap-0.5.8/tests/starttls-logindisabled/ 0000775 0000000 0000000 00000000000 15003201720 0021322 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/starttls-logindisabled/interimap.remote 0000664 0000000 0000000 00000000072 15003201720 0024526 0 ustar 00root root 0000000 0000000 type = imap host = 127.0.0.1 port = 10143 SSL_verify = no interimap-0.5.8/tests/starttls-logindisabled/remote.conf 0000664 0000000 0000000 00000000233 15003201720 0023462 0 ustar 00root root 0000000 0000000 !include conf.d/imapd.conf !include conf.d/ssl.conf # trick dovecot into treating local connections as insecure imap_capability { LOGINDISABLED = yes } interimap-0.5.8/tests/starttls-logindisabled/t 0000664 0000000 0000000 00000001531 15003201720 0021510 0 ustar 00root root 0000000 0000000 interimap --debug || true # double check the presence of 'LOGINDISABLED' and 'STARTTLS' in the preauth capability list grep -oE -m1 '^remote: S: \* OK \[CAPABILITY IMAP4rev1( [^]]*)? AUTH=[^]]*\]' <"$STDERR" >"$TMPDIR/capability" sed -ri 's/^remote: S: \* OK \[CAPABILITY (.*)\]$/\1/' "$TMPDIR/capability" tr " " "\\n" <"$TMPDIR/capability" >"$TMPDIR/capabilities" grep -Fx "IMAP4rev1" <"$TMPDIR/capabilities" || error grep -Fx "LOGINDISABLED" <"$TMPDIR/capabilities" || error # make sure we upgraded the connection and check the capability again grep -Fx "STARTTLS" <"$TMPDIR/capabilities" || error grep -Fx "remote: C: 000000 STARTTLS" <"$STDERR" || error grep -Fx "remote: C: 000001 CAPABILITY" <"$STDERR" || error # can't go further as the capability string still has the manually # enforced 'LOGINDISABLED' # vim: set filetype=bash : interimap-0.5.8/tests/starttls/ 0000775 0000000 0000000 00000000000 15003201720 0016524 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/starttls/interimap.remote 0000664 0000000 0000000 00000000072 15003201720 0021730 0 ustar 00root root 0000000 0000000 type = imap host = 127.0.0.1 port = 10143 SSL_verify = no interimap-0.5.8/tests/starttls/remote.conf 0000664 0000000 0000000 00000000064 15003201720 0020666 0 ustar 00root root 0000000 0000000 !include conf.d/imapd.conf !include conf.d/ssl.conf interimap-0.5.8/tests/starttls/t 0000664 0000000 0000000 00000002361 15003201720 0016714 0 ustar 00root root 0000000 0000000 ssl_server_cert_file="$(doveconf -c "$HOME_remote/.dovecot/config" -hx ssl_server/cert_file)" X509_SHA256="$(openssl x509 -in "$ssl_server_cert_file" -noout -fingerprint -sha256 \ | sed -rn "/^.*=\\s*/ {s///p;q}" | tr -d : | tr "[A-Z]" "[a-z]")" for ((i = 0; i < 32; i++)); do u="$(shuf -n1 -e "local" "remote")" sample_message | deliver -u "$u" done interimap --debug || error # double check the presence of 'STARTTLS' in the preauth capability list grep -oE -m1 '^remote: S: \* OK \[CAPABILITY IMAP4rev1( [^]]*)? AUTH=[^]]*\]' <"$STDERR" >"$TMPDIR/capability" sed -ri 's/^remote: S: \* OK \[CAPABILITY (.*)\]$/\1/' "$TMPDIR/capability" tr " " "\\n" <"$TMPDIR/capability" >"$TMPDIR/capabilities" grep -Fx "IMAP4rev1" <"$TMPDIR/capabilities" || error grep -Fx "STARTTLS" <"$TMPDIR/capabilities" || error # make sure we upgraded the connection and check the capability again grep -Fx "remote: C: 000000 STARTTLS" <"$STDERR" || error grep -Fx "remote: C: 000001 CAPABILITY" <"$STDERR" || error grep -Fx "remote: Peer certificate fingerprint: sha256\$$X509_SHA256" <"$STDERR" || error grep "^remote: SSL protocol: TLSv" <"$STDERR" || error grep "^remote: SSL cipher: " <"$STDERR" || error check_mailbox_status "INBOX" # vim: set filetype=bash : interimap-0.5.8/tests/sync-live-crippled/ 0000775 0000000 0000000 00000000000 15003201720 0020355 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/sync-live-crippled/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0032177 2../auth-sasl-plain/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/sync-live-crippled/local.conf 0000777 0000000 0000000 00000000000 15003201720 0026376 2../sync-live/local.conf ustar 00root root 0000000 0000000 interimap-0.5.8/tests/sync-live-crippled/remote.conf 0000664 0000000 0000000 00000000167 15003201720 0022523 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ^ } !include conf.d/imapd.conf !include conf.d/interimap-required-capabilities.conf interimap-0.5.8/tests/sync-live-crippled/t 0000777 0000000 0000000 00000000000 15003201720 0023050 2../sync-live/t ustar 00root root 0000000 0000000 interimap-0.5.8/tests/sync-live-multi/ 0000775 0000000 0000000 00000000000 15003201720 0017705 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/sync-live-multi/interimap1.local 0000664 0000000 0000000 00000000026 15003201720 0022770 0 ustar 00root root 0000000 0000000 list-reference = foo/ interimap-0.5.8/tests/sync-live-multi/interimap2.local 0000664 0000000 0000000 00000000026 15003201720 0022771 0 ustar 00root root 0000000 0000000 list-reference = bar/ interimap-0.5.8/tests/sync-live-multi/interimap3.local 0000664 0000000 0000000 00000000026 15003201720 0022772 0 ustar 00root root 0000000 0000000 list-reference = baz/ interimap-0.5.8/tests/sync-live-multi/local.conf 0000664 0000000 0000000 00000001117 15003201720 0021646 0 ustar 00root root 0000000 0000000 namespace inbox { separator = / mail_driver = sdbox mail_path = ~/inbox inbox = yes list = yes } namespace foo { separator = / prefix = foo/ mail_driver = sdbox mail_path = ~/foo inbox = no list = yes } namespace bar { separator = / prefix = bar/ mail_driver = sdbox mail_path = ~/bar inbox = no list = yes } namespace baz { separator = / prefix = baz/ mail_driver = sdbox mail_path = ~/baz inbox = no list = yes } interimap-0.5.8/tests/sync-live-multi/remote1.conf 0000664 0000000 0000000 00000000046 15003201720 0022130 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ^ } interimap-0.5.8/tests/sync-live-multi/remote2.conf 0000664 0000000 0000000 00000000051 15003201720 0022125 0 ustar 00root root 0000000 0000000 namespace inbox { separator = "\\" } interimap-0.5.8/tests/sync-live-multi/remote3.conf 0000664 0000000 0000000 00000000050 15003201720 0022125 0 ustar 00root root 0000000 0000000 namespace inbox { separator = "?" } interimap-0.5.8/tests/sync-live-multi/remotes 0000664 0000000 0000000 00000000002 15003201720 0021276 0 ustar 00root root 0000000 0000000 3 interimap-0.5.8/tests/sync-live-multi/t 0000664 0000000 0000000 00000011326 15003201720 0020076 0 ustar 00root root 0000000 0000000 TIMEOUT=60 # mailbox list (as seen on local) and alphabet declare -a MAILBOXES=( "INBOX" ) ALPHABET=() str="#+-0123456789@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz" for ((i=0; i < ${#str}; i++)); do ALPHABET[i]="${str:i:1}" done declare -a TARGETS=( "local" "remote1" "remote2" "remote3" ) # create databases interimap_init "remote1" interimap_init "remote2" interimap_init "remote3" # start long-lived interimap processes declare -a PID=() trap 'ptree_abort ${PID[@]}' EXIT INT TERM interimap --config="config1" --watch=1 & PID+=( $! ) interimap --config="config2" --watch=1 & PID+=( $! ) interimap --config="config3" --watch=1 & PID+=( $! ) timer=$(( $(date +%s) + TIMEOUT )) while [ $(date +%s) -le $timer ]; do # create new mailbox with 10% probability if [ $(shuf -n1 -i0-9) -eq 0 ]; then u="$(shuf -n1 -e -- "${TARGETS[@]}")" # choose target at random case "$u" in local) ns="$(shuf -n1 -e "foo/" "bar/" "baz/")";; remote1) ns="foo/";; remote2) ns="bar/";; remote3) ns="baz/";; *) error "Uh?";; esac m= d=$(shuf -n1 -i1-3) # random depth for (( i=0; i < d; i++)); do l=$(shuf -n1 -i1-16) m="${m:+$m/}$(shuf -n "$l" -e -- "${ALPHABET[@]}" | tr -d '\n')" done MAILBOXES+=( "$ns$m" ) case "$u" in local) m="$ns$m";; remote1) m="${m//\//^}";; remote2) m="${m//\//\\}";; remote3) m="${m//\//\?}";; *) error "Uh?";; esac doveadm -u "$u" mailbox create --no-userdb-lookup -- "$m" fi # EXPUNGE some messages u="$(shuf -n1 -e -- "${TARGETS[@]}")" # choose target at random n="$(shuf -n1 -i0-3)" while read guid uid; do doveadm -u "$u" expunge --no-userdb-lookup mailbox-guid "$guid" uid "$uid" done < <(doveadm -u "$u" search --no-userdb-lookup all | shuf -n "$n") # mark some existing messages as read (toggle \Seen flag as unlike other # flags it's easier to query and check_mailboxes_status checks it) u="$(shuf -n1 -e -- "${TARGETS[@]}")" # choose target at random n="$(shuf -n1 -i0-9)" while read guid uid; do a="$(shuf -n1 -e add remove replace)" doveadm -u "$u" flags "$a" --no-userdb-lookup "\\Seen" mailbox-guid "$guid" uid "$uid" done < <(doveadm -u "$u" search --no-userdb-lookup all | shuf -n "$n") # select at random a mailbox where to deliver some messages u="$(shuf -n1 -e "local" "remote")" # choose target at random m="$(shuf -n1 -e -- "${MAILBOXES[@]}")" if [ "$u" = "remote" ]; then case "$m" in foo/*) u="remote1"; m="${m#foo/}"; m="${m//\//^}";; bar/*) u="remote2"; m="${m#bar/}"; m="${m//\//\\}";; baz/*) u="remote3"; m="${m#baz/}"; m="${m//\//\?}";; INBOX) u="$(shuf -n1 -e "remote1" "remote2" "remote3")";; *) error "Uh? $m";; esac fi # deliver between 1 and 5 messages to the chosen mailbox n="$(shuf -n1 -i1-5)" for (( i=0; i < n; i++)); do sample_message | deliver -u "$u" -- -m "$m" done # sleep a little bit (sometimes beyond --watch timer, sometimes not) s=$(shuf -n1 -i1-1500) [ $s -ge 1000 ] && s="$(printf "1.%03d" $((s-1000)))" || s="$(printf "0.%03d" $s)" sleep "$s" done # wait a little longer so interimap has time to run loop() again and # synchronize outstanding changes, then terminate the processes we # started above sleep 5 ptree_abort ${PID[@]} trap - EXIT INT TERM # check that the mailbox lists match diff -u --label="local/mailboxes" --label="remote1/mailboxes" \ <( doveadm -u "local" mailbox list --no-userdb-lookup | sed -n "s,^foo/,,p" | sort ) \ <( doveadm -u "remote1" mailbox list --no-userdb-lookup | tr '^' '/' | sort ) diff -u --label="local/mailboxes" --label="remote2/mailboxes" \ <( doveadm -u "local" mailbox list --no-userdb-lookup | sed -n "s,^bar/,,p" | sort ) \ <( doveadm -u "remote2" mailbox list --no-userdb-lookup | tr '\\' '/' | sort ) diff -u --label="local/mailboxes" --label="remote3/mailboxes" \ <( doveadm -u "local" mailbox list --no-userdb-lookup | sed -n "s,^baz/,,p" | sort ) \ <( doveadm -u "remote3" mailbox list --no-userdb-lookup | tr '?' '/' | sort ) for m in "${MAILBOXES[@]}"; do case "$m" in foo/*) u="remote1"; mb="${m#foo/}"; mr="${mb//\//^}";; bar/*) u="remote2"; mb="${m#bar/}"; mr="${mb//\//\\}";; baz/*) u="remote3"; mb="${m#baz/}"; mr="${mb//\//\?}";; INBOX) continue;; *) error "Uh? $m";; esac blob="x'$(printf "%s" "$mb" | tr "/" "\\0" | xxd -c256 -ps)'" check_mailbox_status2 "$blob" "$m" "$u" "$mr" done # vim: set filetype=bash : interimap-0.5.8/tests/sync-live-tls/ 0000775 0000000 0000000 00000000000 15003201720 0017355 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/sync-live-tls/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0026777 2../tls/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/sync-live-tls/local.conf 0000777 0000000 0000000 00000000000 15003201720 0025376 2../sync-live/local.conf ustar 00root root 0000000 0000000 interimap-0.5.8/tests/sync-live-tls/remote.conf 0000664 0000000 0000000 00000000133 15003201720 0021514 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ^ } !include conf.d/imapd.conf !include conf.d/ssl.conf interimap-0.5.8/tests/sync-live-tls/t 0000777 0000000 0000000 00000000000 15003201720 0022050 2../sync-live/t ustar 00root root 0000000 0000000 interimap-0.5.8/tests/sync-live/ 0000775 0000000 0000000 00000000000 15003201720 0016555 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/sync-live/local.conf 0000664 0000000 0000000 00000000046 15003201720 0020516 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/sync-live/remote.conf 0000664 0000000 0000000 00000000046 15003201720 0020717 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ^ } interimap-0.5.8/tests/sync-live/t 0000664 0000000 0000000 00000005217 15003201720 0016750 0 ustar 00root root 0000000 0000000 TIMEOUT=60 # mailbox list and alphabet (exclude &, / and ~, which dovecot treats specially) declare -a MAILBOXES=( "INBOX" ) ALPHABET=() str="!\"#\$'()+,-0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ\[\\\]_\`abcdefghijklmnopqrstuvwxyz{|}" for ((i=0; i < ${#str}; i++)); do ALPHABET[i]="${str:i:1}" done interimap_init # start a long-lived interimap process interimap --watch=1 & PID=$! trap "ptree_abort $PID" EXIT INT TERM timer=$(( $(date +%s) + TIMEOUT )) while [ $(date +%s) -le $timer ]; do # create new mailbox with 10% probability if [ $(shuf -n1 -i0-9) -eq 0 ]; then m= d=$(shuf -n1 -i1-3) # random depth for (( i=0; i < d; i++)); do l=$(shuf -n1 -i1-16) m="${m:+$m.}$(shuf -n "$l" -e -- "${ALPHABET[@]}" | tr -d '\n')" done MAILBOXES+=( "$m" ) u="$(shuf -n1 -e "local" "remote")" # choose target at random [ "$u" = "local" ] || m="${m//./^}" doveadm -u "$u" mailbox create --no-userdb-lookup -- "$m" fi # EXPUNGE some messages u="$(shuf -n1 -e "local" "remote")" # choose target at random n="$(shuf -n1 -i0-3)" while read guid uid; do doveadm -u "$u" expunge --no-userdb-lookup mailbox-guid "$guid" uid "$uid" done < <(doveadm -u "$u" search --no-userdb-lookup all | shuf -n "$n") # mark some existing messages as read (toggle \Seen flag as unlike other # flags it's easier to query and check_mailboxes_status checks it) u="$(shuf -n1 -e "local" "remote")" # choose target at random n="$(shuf -n1 -i0-9)" while read guid uid; do a="$(shuf -n1 -e add remove replace)" doveadm -u "$u" flags "$a" --no-userdb-lookup "\\Seen" mailbox-guid "$guid" uid "$uid" done < <(doveadm -u "$u" search --no-userdb-lookup all | shuf -n "$n") # select at random a mailbox where to deliver some messages u="$(shuf -n1 -e "local" "remote")" # choose target at random m="$(shuf -n1 -e -- "${MAILBOXES[@]}")" [ "$u" = "local" ] || m="${m//./^}" # deliver between 1 and 5 messages to the chosen mailbox n="$(shuf -n1 -i1-5)" for (( i=0; i < n; i++)); do sample_message | deliver -u "$u" -- -m "$m" done # sleep a little bit (sometimes beyond --watch timer, sometimes not) s=$(shuf -n1 -i1-1500) [ $s -ge 1000 ] && s="$(printf "1.%03d" $((s-1000)))" || s="$(printf "0.%03d" $s)" sleep "$s" done # wait a little longer so interimap has time to run loop() again and # synchronize outstanding changes, then terminate the process we started # above sleep 5 ptree_abort $PID trap - EXIT INT TERM check_mailbox_list check_mailboxes_status "${MAILBOXES[@]}" # vim: set filetype=bash : interimap-0.5.8/tests/sync-mailbox-list/ 0000775 0000000 0000000 00000000000 15003201720 0020222 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/sync-mailbox-list/local.conf 0000664 0000000 0000000 00000000046 15003201720 0022163 0 ustar 00root root 0000000 0000000 namespace inbox { separator = . } interimap-0.5.8/tests/sync-mailbox-list/remote.conf 0000664 0000000 0000000 00000000046 15003201720 0022364 0 ustar 00root root 0000000 0000000 namespace inbox { separator = ~ } interimap-0.5.8/tests/sync-mailbox-list/t 0000664 0000000 0000000 00000006624 15003201720 0020420 0 ustar 00root root 0000000 0000000 # pre-create some mailboxes and susbscribe to some # foo: present on both, subscribed to both # bar: present on both, subscribed to local only # baz: present on both, subscribed to remote only # foo.bar: present on local only # foo.baz: present on remote only doveadm -u "local" mailbox create --no-userdb-lookup "foo" "bar" "baz" "foo.bar" "fo!o [b*a%r]" doveadm -u "local" mailbox subscribe --no-userdb-lookup "foo" "bar" doveadm -u "remote" mailbox create --no-userdb-lookup "foo" "bar" "baz" "foo~baz" "foo]bar" doveadm -u "remote" mailbox subscribe --no-userdb-lookup "foo" "baz" populate() { local i for ((i = 0; i < 32; i++)); do m="$(shuf -n1 -e -- "foo" "bar" "baz" "foo.bar" "fo!o [b*a%r]")" sample_message | deliver -u "local" -- -m "$m" m="$(shuf -n1 -e -- "foo" "bar" "baz" "foo~baz" "foo]bar")" sample_message | deliver -u "remote" -- -m "$m" done } verify() { check_mailbox_list || error check_mailboxes_status "foo" "bar" "baz" "foo.bar" "foo.baz" "INBOX" "fo!o [b*a%r]" "foo]bar" } populate step_start "pre-subscribtions" interimap_init grep -Fx "local: Subscribe to baz" <"$STDERR" || error grep -Fx "remote: Subscribe to bar" <"$STDERR" || error grep -Fx "local: Created mailbox foo.baz" <"$STDERR" || error grep -Fx "remote: Created mailbox foo~bar" <"$STDERR" || error step_done # ensure the mailbox list is synchronized step_start "mailbox list and content" verify check_mailbox_list -s step_done # delete a mailbox on one server and verify that synchronization fails as it's still in the database step_start "aborts if present in database" for u in "local" "remote"; do [ "$u" = "local" ] && { m="foo.bar"; m2="$m"; } || { m="foo.baz"; m2="foo~baz"; } doveadm -u "$u" mailbox delete --no-userdb-lookup "$m2" ! interimap || error grep -Fx "database: ERROR: Mailbox $m exists. Run \`interimap --target=database --delete $m\` to delete." <"$STDERR" interimap --target="database" --delete "$m" || error grep -Fx "database: Removed mailbox $m" <"$STDERR" || error interimap || error # create again grep -Fx "database: Created mailbox $m" <"$STDERR" || error grep -Fx "$u: Created mailbox $m2" <"$STDERR" || error done verify check_mailbox_list -s step_done # (un)subscribe from some mailboxes, including a non-existent one step_start "new (un)subscribtions" doveadm -u "local" mailbox unsubscribe --no-userdb-lookup "foo" doveadm -u "remote" mailbox unsubscribe --no-userdb-lookup "bar" doveadm -u "local" mailbox subscribe --no-userdb-lookup "foo.bar" "foo.nonexistent" "foo.baz" doveadm -u "remote" mailbox subscribe --no-userdb-lookup "foo~bar" "bar~nonexistent" populate interimap grep -Fx "remote: Unsubscribe to foo" <"$STDERR" grep -Fx "local: Unsubscribe to bar" <"$STDERR" grep -Fx "remote: Subscribe to foo~baz" <"$STDERR" verify check_mailbox_list -s $(doveadm -u "local" mailbox list --no-userdb-lookup) # exclude "foo.nonexistent" and "bar~nonexistent" # check that "baz", "foo.bar" and "foo.baz" are the only subscribed mailboxes sqlite3 "$XDG_DATA_HOME/interimap/remote.db" >"$TMPDIR/count" <<-EOF SELECT COUNT(*) FROM mailboxes WHERE subscribed <> (mailbox IN ( x'$(printf "%s" "baz" | xxd -ps)', x'$(printf "%s\\0%s" "foo" "bar" | xxd -ps)', x'$(printf "%s\\0%s" "foo" "baz" | xxd -ps)' )) EOF [ $(< "$TMPDIR/count") -eq 0 ] || error step_done # vim: set filetype=bash : interimap-0.5.8/tests/tls-ciphers/ 0000775 0000000 0000000 00000000000 15003201720 0017101 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-ciphers/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0026523 2../tls/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-ciphers/remote.conf 0000777 0000000 0000000 00000000000 15003201720 0024415 2../tls/remote.conf ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-ciphers/t 0000664 0000000 0000000 00000002204 15003201720 0017265 0 ustar 00root root 0000000 0000000 # backup config install -m0600 "$XDG_CONFIG_HOME/interimap/config" "$XDG_CONFIG_HOME/interimap/config~" with_remote_config() { install -m0600 "$XDG_CONFIG_HOME/interimap/config~" "$XDG_CONFIG_HOME/interimap/config" cat >>"$XDG_CONFIG_HOME/interimap/config" } with_remote_config <<-EOF SSL_protocol_max = TLSv1.2 SSL_cipherlist = DHE-RSA-AES128-SHA256:ALL:!COMPLEMENTOFDEFAULT:!eNULL EOF interimap --debug || error grep -Fx "remote: SSL cipher: DHE-RSA-AES128-SHA256 (128 bits)" <"$STDERR" || error with_remote_config <<-EOF SSL_protocol_max = TLSv1.2 SSL_cipherlist = NONEXISTENT:ECDHE-RSA-AES256-SHA384:ALL:!COMPLEMENTOFDEFAULT:!eNULL SSL_ciphersuites = TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256 EOF interimap --debug || error grep -Fx "remote: SSL cipher: ECDHE-RSA-AES256-SHA384 (256 bits)" <"$STDERR" || error with_remote_config <<-EOF SSL_protocol_min = TLSv1.3 SSL_cipherlist = DHE-RSA-AES128-SHA256 SSL_ciphersuites = TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256 EOF interimap --debug || error grep -Fx "remote: SSL cipher: TLS_CHACHA20_POLY1305_SHA256 (256 bits)" <"$STDERR" || error # vim: set filetype=bash : interimap-0.5.8/tests/tls-pin-fingerprint/ 0000775 0000000 0000000 00000000000 15003201720 0020557 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-pin-fingerprint/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0030201 2../tls/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-pin-fingerprint/remote.conf 0000777 0000000 0000000 00000000000 15003201720 0026073 2../tls/remote.conf ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-pin-fingerprint/t 0000664 0000000 0000000 00000005502 15003201720 0020747 0 ustar 00root root 0000000 0000000 ssl_server_cert_file="$(doveconf -c "$HOME_remote/.dovecot/config" -hx ssl_server/cert_file)" PKEY_SHA256="$(openssl x509 -in "$ssl_server_cert_file" -pubkey \ | openssl pkey -in /dev/stdin -pubin -outform DER \ | openssl dgst -sha256 | sed -rn "/^.*=\\s*/ {s///p;q}")" INVALID_FPR="sha256\$deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" INVALID_FPR2="sha256\$deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee2" # backup config install -m0600 "$XDG_CONFIG_HOME/interimap/config" "$XDG_CONFIG_HOME/interimap/config~" with_remote_config() { install -m0600 "$XDG_CONFIG_HOME/interimap/config~" "$XDG_CONFIG_HOME/interimap/config" cat >>"$XDG_CONFIG_HOME/interimap/config" } # pinned valid fingerprint with_remote_config <<-EOF SSL_fingerprint = sha256\$$PKEY_SHA256 EOF for ((i = 0; i < 32; i++)); do u="$(shuf -n1 -e "local" "remote")" sample_message | deliver -u "$u" done interimap_init check_mailbox_status "INBOX" # with default algorithm (SHA256) with_remote_config <<-EOF SSL_fingerprint = $INVALID_FPR $PKEY_SHA256 EOF interimap --debug || error grep -Fx "remote: Peer certificate matches pinned SPKI digest sha256\$$PKEY_SHA256" <"$STDERR" || error # and now an invalid one with_remote_config <<-EOF SSL_fingerprint = $INVALID_FPR EOF ! interimap --debug || error grep -Fx "remote: ERROR: Can't initiate TLS/SSL handshake" <"$STDERR" || error grep -Fx "remote: WARNING: Fingerprint doesn't match! MiTM in action?" <"$STDERR" || error grep -Fx "remote: ERROR: Can't initiate TLS/SSL handshake" <"$STDERR" || error # make sure we didn't send any credentials or started speaking IMAP ! grep -E "^remote: C: .* (AUTHENTICATE|LOGIN) " <"$STDERR" || error grep -Fx "remote: IMAP traffic (bytes): recv 0 sent 0" <"$STDERR" || error # two invalid ones with_remote_config <<-EOF SSL_fingerprint = $INVALID_FPR $INVALID_FPR2 EOF ! interimap --debug || error grep -Fx "remote: ERROR: Can't initiate TLS/SSL handshake" <"$STDERR" || error grep -Fx "remote: WARNING: Fingerprint doesn't match! MiTM in action?" <"$STDERR" || error grep -Fx "remote: ERROR: Can't initiate TLS/SSL handshake" <"$STDERR" || error # make sure we didn't send any credentials or started speaking IMAP ! grep -E "^remote: C: .* (AUTHENTICATE|LOGIN) " <"$STDERR" || error grep -Fx "remote: IMAP traffic (bytes): recv 0 sent 0" <"$STDERR" || error # valid + invalid with_remote_config <<-EOF SSL_fingerprint = sha256\$$PKEY_SHA256 $INVALID_FPR EOF interimap --debug || error grep -Fx "remote: Peer certificate matches pinned SPKI digest sha256\$$PKEY_SHA256" <"$STDERR" || error # invalid + valid with_remote_config <<-EOF SSL_fingerprint = $INVALID_FPR sha256\$$PKEY_SHA256 EOF interimap --debug || error grep -Fx "remote: Peer certificate matches pinned SPKI digest sha256\$$PKEY_SHA256" <"$STDERR" || error # vim: set filetype=bash : interimap-0.5.8/tests/tls-protocols/ 0000775 0000000 0000000 00000000000 15003201720 0017470 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-protocols/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0027112 2../tls/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-protocols/openssl.cnf 0000664 0000000 0000000 00000000454 15003201720 0021646 0 ustar 00root root 0000000 0000000 # as we want to test TLSv1 we need to set MinProtocol=None, see # see /usr/share/doc/libssl1.1/NEWS.Debian.gz openssl_conf = default_conf [default_conf] ssl_conf = ssl_sect [ssl_sect] system_default = system_default_sect [system_default_sect] MinProtocol = None CipherString = DEFAULT@SECLEVEL=0 interimap-0.5.8/tests/tls-protocols/remote.conf 0000664 0000000 0000000 00000000162 15003201720 0021631 0 ustar 00root root 0000000 0000000 !include conf.d/imapd.conf !include conf.d/ssl.conf ssl_min_protocol = TLSv1 ssl_cipher_list = DEFAULT@SECLEVEL=0 interimap-0.5.8/tests/tls-protocols/t 0000664 0000000 0000000 00000010777 15003201720 0017672 0 ustar 00root root 0000000 0000000 # system default interimap --debug || error ! grep -E "^remote: Disabling SSL protocols: " <"$STDERR" || error # TODO deprecated ! grep -E "^remote: Minimum SSL/TLS protocol version: " <"$STDERR" || error ! grep -E "^remote: Maximum SSL/TLS protocol version: " <"$STDERR" || error grep -E "^remote: SSL protocol: TLSv" <"$STDERR" || error # load custom OpenSSL configuration to allow TLS protocol version <=1.1 export OPENSSL_CONF="$TESTDIR/openssl.cnf" # backup config install -m0600 "$XDG_CONFIG_HOME/interimap/config" "$XDG_CONFIG_HOME/interimap/config~" with_remote_tls_protocols() { install -m0600 "$XDG_CONFIG_HOME/interimap/config~" "$XDG_CONFIG_HOME/interimap/config" printf "SSL_protocols = %s\\n" "$*" >>"$XDG_CONFIG_HOME/interimap/config" } # disable TLSv1.2 and earlier with_remote_tls_protocols "!SSLv2" "!SSLv3" "!TLSv1" "!TLSv1.1" "!TLSv1.2" interimap --debug || error grep -Fx "remote: Disabling SSL protocols: SSLv3, TLSv1, TLSv1.1, TLSv1.2" <"$STDERR" || error grep -E "^remote: SSL protocol: TLSv1\.3 " <"$STDERR" || error interimap || error grep -E "^remote: WARNING: SSL_protocols is deprecated " <"$STDERR" || error "no deprecation warning" # force TLSv1.2 with_remote_tls_protocols "TLSv1.2" interimap --debug || error grep -Fx "remote: Disabling SSL protocols: SSLv3, TLSv1, TLSv1.1, TLSv1.3" <"$STDERR" || error grep -E "^remote: SSL protocol: TLSv1\.2 " <"$STDERR" || error # force TLSv1 to TLSv1.2 with_remote_tls_protocols "TLSv1" "TLSv1.1" "TLSv1.2" interimap --debug || error grep -Fx "remote: Disabling SSL protocols: SSLv3, TLSv1.3" <"$STDERR" || error grep -E "^remote: SSL protocol: TLSv(1\.[12])? " <"$STDERR" || error # force SSLv2 and SSLv3; this fails due to dovecot's ssl_min_protocol=TLSv1 with_remote_tls_protocols "SSLv2" "SSLv3" ! interimap --debug || error grep -Fx "remote: Disabling SSL protocols: TLSv1, TLSv1.1, TLSv1.2, TLSv1.3" <"$STDERR" || error grep -Fx "remote: ERROR: Can't initiate TLS/SSL handshake" <"$STDERR" || error # make sure we didn't send any credentials or started speaking IMAP ! grep -E "^remote: C: .* (AUTHENTICATE|LOGIN) " <"$STDERR" || error grep -Fx "remote: IMAP traffic (bytes): recv 0 sent 0" <"$STDERR" || error # new interface: SSL_protocol_{min,max} with_remote_tls_protocol_min_max() { install -m0600 "$XDG_CONFIG_HOME/interimap/config~" "$XDG_CONFIG_HOME/interimap/config" if [ -n "${1-}" ]; then printf "SSL_protocol_min = %s\\n" "$1" >>"$XDG_CONFIG_HOME/interimap/config" fi if [ -n "${2-}" ]; then printf "SSL_protocol_max = %s\\n" "$2" >>"$XDG_CONFIG_HOME/interimap/config" fi } # disable TLSv1.2 and earlier # XXX this test assumes that TLSv1.3 is the highest version supported with_remote_tls_protocol_min_max "TLSv1.3" interimap --debug || error grep -Fx "remote: Minimum SSL/TLS protocol version: TLSv1.3" <"$STDERR" || error ! grep -E "^remote: Maximum SSL/TLS protocol version: " <"$STDERR" || error grep -E "^remote: SSL protocol: TLSv1\.3 " <"$STDERR" || error # force TLSv1.2 with_remote_tls_protocol_min_max "TLSv1.2" "TLSv1.2" interimap --debug || error grep -Fx "remote: Minimum SSL/TLS protocol version: TLSv1.2" <"$STDERR" || error grep -Fx "remote: Maximum SSL/TLS protocol version: TLSv1.2" <"$STDERR" || error grep -E "^remote: SSL protocol: TLSv1\.2 " <"$STDERR" || error # disable TLSv1.2 and later with_remote_tls_protocol_min_max "" "TLSv1.1" interimap --debug || error ! grep -E "^remote: Minimum SSL/TLS protocol version: " <"$STDERR" || error grep -Fx "remote: Maximum SSL/TLS protocol version: TLSv1.1" <"$STDERR" || error grep -E "^remote: SSL protocol: TLSv1\.1 " <"$STDERR" || error # force SSLv3 to to TLSv1.1 with_remote_tls_protocol_min_max "SSLv3" "TLSv1.1" interimap --debug || error grep -Fx "remote: Minimum SSL/TLS protocol version: SSLv3" <"$STDERR" || error grep -Fx "remote: Maximum SSL/TLS protocol version: TLSv1.1" <"$STDERR" || error grep -E "^remote: SSL protocol: TLSv1(\.1)? " <"$STDERR" || error # force SSLv3; this fails due to dovecot's ssl_min_protocol=TLSv1 with_remote_tls_protocol_min_max "SSLv3" "SSLv3" ! interimap --debug || error grep -Fx "remote: Minimum SSL/TLS protocol version: SSLv3" <"$STDERR" || error grep -Fx "remote: Maximum SSL/TLS protocol version: SSLv3" <"$STDERR" || error grep -Fx "remote: ERROR: Can't initiate TLS/SSL handshake" <"$STDERR" || error # make sure we didn't send any credentials or started speaking IMAP ! grep -E "^remote: C: .* (AUTHENTICATE|LOGIN) " <"$STDERR" || error grep -Fx "remote: IMAP traffic (bytes): recv 0 sent 0" <"$STDERR" || error # vim: set filetype=bash : interimap-0.5.8/tests/tls-rsa+ecdsa/ 0000775 0000000 0000000 00000000000 15003201720 0017304 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-rsa+ecdsa/interimap.remote 0000777 0000000 0000000 00000000000 15003201720 0026726 2../tls/interimap.remote ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-rsa+ecdsa/remote.conf 0000664 0000000 0000000 00000000234 15003201720 0021445 0 ustar 00root root 0000000 0000000 !include conf.d/imapd.conf !include conf.d/ssl.conf ssl_server_alt_cert_file = conf.d/dovecot.ecdsa.crt ssl_server_alt_key_file = conf.d/dovecot.ecdsa.key interimap-0.5.8/tests/tls-rsa+ecdsa/t 0000664 0000000 0000000 00000004500 15003201720 0017471 0 ustar 00root root 0000000 0000000 doveconf_remote() { local p k="$1" p="$(doveconf -c "$HOME_remote/.dovecot/config" -hx "$1")" cat <"$p" } pkey_sha256() { openssl x509 -in /dev/stdin -pubkey \ | openssl pkey -in /dev/stdin -pubin -outform DER \ | openssl dgst -sha256 | sed -rn "/^.*=\\s*/ {s///p;q}" } x509_sha256() { openssl x509 -in /dev/stdin -noout -fingerprint -sha256 \ | sed -rn "/^.*=\\s*/ {s///p;q}" | tr -d : | tr "[A-Z]" "[a-z]" } PKEY_SHA256="$(doveconf_remote ssl_server/cert_file | pkey_sha256)" X509_SHA256="$(doveconf_remote ssl_server/cert_file | x509_sha256)" PKEY_ALT_SHA256="$(doveconf_remote ssl_server/alt_cert_file | pkey_sha256)" X509_ALT_SHA256="$(doveconf_remote ssl_server/alt_cert_file | x509_sha256)" # pinned valid fingerprints cat >>"$XDG_CONFIG_HOME/interimap/config" <<-EOF SSL_fingerprint = sha256\$$PKEY_SHA256 sha256\$$PKEY_ALT_SHA256 EOF for ((i = 0; i < 32; i++)); do u="$(shuf -n1 -e "local" "remote")" sample_message | deliver -u "$u" done interimap_init check_mailbox_status "INBOX" interimap --debug || error # which peer certificate is used is up to libssl grep -Fx -e "remote: Peer certificate fingerprint: sha256\$$X509_SHA256" \ -e "remote: Peer certificate fingerprint: sha256\$$X509_ALT_SHA256" \ <"$STDERR" || error grep -Fx -e "remote: Peer certificate matches pinned SPKI digest sha256\$$PKEY_SHA256" \ -e "remote: Peer certificate matches pinned SPKI digest sha256\$$PKEY_ALT_SHA256" \ <"$STDERR" || error # force RSA # XXX we also have to force TLS <=1.2 here as the TLSv1.3 ciphersuites # don't specify the certificate type (nor key exchange) cat >>"$XDG_CONFIG_HOME/interimap/config" <<-EOF SSL_protocol_max = TLSv1.2 SSL_cipherlist = EECDH+AESGCM+aRSA EOF interimap --debug || error grep -Fx "remote: Peer certificate fingerprint: sha256\$$X509_SHA256" <"$STDERR" || error grep -Fx "remote: Peer certificate matches pinned SPKI digest sha256\$$PKEY_SHA256" <"$STDERR" || error # force ECDSA sed -i "s/^SSL_cipherlist\\s*=.*/SSL_cipherlist = EECDH+AESGCM+aECDSA/" -- "$XDG_CONFIG_HOME/interimap/config" interimap --debug || error grep -Fx "remote: Peer certificate fingerprint: sha256\$$X509_ALT_SHA256" <"$STDERR" || error grep -Fx "remote: Peer certificate matches pinned SPKI digest sha256\$$PKEY_ALT_SHA256" <"$STDERR" || error # vim: set filetype=bash : interimap-0.5.8/tests/tls-sni/ 0000775 0000000 0000000 00000000000 15003201720 0016235 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-sni/interimap.remote 0000664 0000000 0000000 00000000052 15003201720 0021437 0 ustar 00root root 0000000 0000000 type = imaps port = 10993 SSL_verify = no interimap-0.5.8/tests/tls-sni/remote.conf 0000664 0000000 0000000 00000000272 15003201720 0020400 0 ustar 00root root 0000000 0000000 !include conf.d/imapd.conf !include conf.d/ssl.conf local_name imap.example.net { ssl_server_cert_file = conf.d/dovecot.rsa2.crt ssl_server_key_file = conf.d/dovecot.rsa2.key } interimap-0.5.8/tests/tls-sni/t 0000664 0000000 0000000 00000005701 15003201720 0016426 0 ustar 00root root 0000000 0000000 SERVERNAME="imap.example.net" # cf local_name{} section in the dovecot config ssl_server_cert_file="$(doveconf -c "$HOME_remote/.dovecot/config" -hx ssl_server/cert_file)" X509_SHA256="$(openssl x509 -in "$ssl_server_cert_file" -noout -fingerprint -sha256 \ | sed -rn "/^.*=\\s*/ {s///p;q}" | tr -d : | tr "[A-Z]" "[a-z]")" ssl_server_cert_file2="$(doveconf -c "$HOME_remote/.dovecot/config" -f local_name="$SERVERNAME" -hx ssl_server/cert_file)" X509_2_SHA256="$(openssl x509 -in "$ssl_server_cert_file2" -noout -fingerprint -sha256 \ | sed -rn "/^.*=\\s*/ {s///p;q}" | tr -d : | tr "[A-Z]" "[a-z]")" # check that empty SSL_hostname disables SNI echo "SSL_hostname =" >>"$XDG_CONFIG_HOME/interimap/config" interimap --debug || error ! grep "^remote: Using SNI with name " <"$STDERR" || error "Empty SSL_hostname didn't disable SNI" # default servername is the host value sed -i "/^SSL_hostname\\s*=/d" -- "$XDG_CONFIG_HOME/interimap/config" interimap --debug || error grep -Fx "remote: Using SNI with name localhost" <"$STDERR" || error "No default SNI" grep -Fx "remote: Peer certificate fingerprint: sha256\$$X509_SHA256" <"$STDERR" || error # verify that SNI is not used when host is an IP echo "host = __INVALID__" >>"$XDG_CONFIG_HOME/interimap/config" for ip in "127.0.0.1" "[::1]"; do sed -i "s/^host\\s*=.*/host = $ip/" -- "$XDG_CONFIG_HOME/interimap/config" interimap --debug || error ! grep "^remote: Using SNI with name " <"$STDERR" || error "Using SNI with IP $ip" grep -Fx "remote: Peer certificate fingerprint: sha256\$$X509_SHA256" <"$STDERR" || error done # verify that SNI actually works (ie we're served the right cert) sni_ok() { grep -Fx "remote: Using SNI with name $SERVERNAME" <"$STDERR" || error grep -Fx "remote: Peer certificate fingerprint: sha256\$$X509_2_SHA256" <"$STDERR" || error } echo "SSL_hostname = $SERVERNAME" >>"$XDG_CONFIG_HOME/interimap/config" interimap --debug || error sni_ok ## make sure SSL_hostname doesn't affect certificate verification ## # bad CA, bad host sed -i "s/^host\\s*=.*/host = 127.0.0.1/" -- "$XDG_CONFIG_HOME/interimap/config" sed -i "s/^SSL_verify\\s*=.*/SSL_verify = YES/" -- "$XDG_CONFIG_HOME/interimap/config" ! interimap --debug || error sni_ok grep -Fx "remote: ERROR: Can't initiate TLS/SSL handshake" <"$STDERR" || error # good CA, bad host echo "SSL_CAfile = $HOME/.dovecot/conf.d/ca.crt" >>"$XDG_CONFIG_HOME/interimap/config" ! interimap --debug || error sni_ok grep -Fx "remote: ERROR: Can't initiate TLS/SSL handshake" <"$STDERR" || error # bad CA, good host sed -i "/^SSL_CAfile\\s*=/d" -- "$XDG_CONFIG_HOME/interimap/config" sed -i "s/^host\\s*=.*/host = localhost/" -- "$XDG_CONFIG_HOME/interimap/config" ! interimap --debug || error sni_ok grep -Fx "remote: ERROR: Can't initiate TLS/SSL handshake" <"$STDERR" || error # good CA, good host echo "SSL_CAfile = $HOME/.dovecot/conf.d/ca.crt" >>"$XDG_CONFIG_HOME/interimap/config" interimap --debug || error sni_ok # vim: set filetype=bash : interimap-0.5.8/tests/tls-verify-peer/ 0000775 0000000 0000000 00000000000 15003201720 0017701 5 ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-verify-peer/interimap.remote 0000664 0000000 0000000 00000000015 15003201720 0023102 0 ustar 00root root 0000000 0000000 port = 10993 interimap-0.5.8/tests/tls-verify-peer/remote.conf 0000777 0000000 0000000 00000000000 15003201720 0025215 2../tls/remote.conf ustar 00root root 0000000 0000000 interimap-0.5.8/tests/tls-verify-peer/t 0000664 0000000 0000000 00000011754 15003201720 0020077 0 ustar 00root root 0000000 0000000 ssl_server_cert_file="$(doveconf -c "$HOME_remote/.dovecot/config" -hx ssl_server/cert_file)" X509_SHA256="$(openssl x509 -in "$ssl_server_cert_file" -noout -fingerprint -sha256 \ | sed -rn "/^.*=\\s*/ {s///p;q}" | tr -d : | tr "[A-Z]" "[a-z]")" PKEY_SHA256="$(openssl x509 -in "$ssl_server_cert_file" -pubkey \ | openssl pkey -in /dev/stdin -pubin -outform DER \ | openssl dgst -sha256 | sed -rn "/^.*=\\s*/ {s///p;q}")" unverified_peer() { ! interimap --debug || error # make sure we aborted the handshake immediately after connecting grep -Fx "remote: Peer certificate fingerprint: sha256\$$X509_SHA256" <"$STDERR" || error grep -Fx "remote: ERROR: Can't initiate TLS/SSL handshake" <"$STDERR" || error sed -nr "s/remote: \[[0-9]+\] (preverify=[0-9]+)$/\1/p" <"$STDERR" >"$TMPDIR/preverify" [ -s "$TMPDIR/preverify" ] || error ! grep -Fvx "preverify=0" <"$TMPDIR/preverify" || error # make sure we didn't send any credentials or started speaking IMAP ! grep -E "^remote: C: .* (AUTHENTICATE|LOGIN) " <"$STDERR" || error grep -Fx "remote: IMAP traffic (bytes): recv 0 sent 0" <"$STDERR" || error } verified_peer() { local i u for ((i = 0; i < 4; i++)); do u="$(shuf -n1 -e "local" "remote")" sample_message | deliver -u "$u" done interimap --debug || error grep -Fx "remote: Peer certificate fingerprint: sha256\$$X509_SHA256" <"$STDERR" || error sed -nr "s/remote: \[[0-9]+\] (preverify=[0-9]+)$/\1/p" <"$STDERR" >"$TMPDIR/preverify" [ -s "$TMPDIR/preverify" ] || error ! grep -Fvx "preverify=1" <"$TMPDIR/preverify" || error grep "^remote: SSL protocol: TLSv" <"$STDERR" || error grep "^remote: SSL cipher: " <"$STDERR" || error check_mailbox_status "INBOX" } # backup config install -m0600 -- "$XDG_CONFIG_HOME/interimap/config" "$XDG_CONFIG_HOME/interimap/config~" with_remote_config() { install -m0600 -- "$XDG_CONFIG_HOME/interimap/config~" "$XDG_CONFIG_HOME/interimap/config" cat >>"$XDG_CONFIG_HOME/interimap/config" } step_start "peer verification enabled by default" # assume our fake root CA is not among OpenSSL's default trusted CAs unverified_peer grep -Fx "remote: Using default locations for trusted CA certificates" <"$STDERR" || error step_done step_start "peer verification result honored when pinned pubkey matches" with_remote_config <<-EOF SSL_fingerprint = sha256\$$PKEY_SHA256 EOF unverified_peer grep -Fx "remote: Using default locations for trusted CA certificates" <"$STDERR" || error grep -Fx "remote: Peer certificate matches pinned SPKI digest sha256\$$PKEY_SHA256" <"$STDERR" || error step_done capath=$(mktemp --tmpdir="$TMPDIR" --directory capath.XXXXXX) cp -T -- ~/.dovecot/conf.d/ca.crt "$capath/ca-certificates.crt" step_start "SSL_CAfile/\$SSL_CERT_FILE" # verify that an error is raised when CAfile can't be loaded # (it's not the case for $SSL_CERT_FILE, cf. SSL_CTX_load_verify_locations(3ssl)) with_remote_config <<<"SSL_CAfile = /nonexistent" ! interimap --debug || error grep -Fx "remote: ERROR: SSL_CTX_load_verify_locations()" <"$STDERR" || error grep -Fx "remote: IMAP traffic (bytes): recv 0 sent 0" <"$STDERR" || error if [ -f "/etc/ssl/certs/ca-certificates.crt" ]; then # assume our fake root CA is not there with_remote_config <<<"SSL_CAfile = /etc/ssl/certs/ca-certificates.crt" unverified_peer fi # default host (localhost) is the CN (and also subjectAltName) with_remote_config <<<"SSL_CAfile = $capath/ca-certificates.crt" verified_peer with_remote_config