pax_global_header00006660000000000000000000000064146631111220014507gustar00rootroot0000000000000052 comment=a8a108e2344656a13bca21211ccc0df2414cbef6 alot-0.11/000077500000000000000000000000001466311112200123675ustar00rootroot00000000000000alot-0.11/.codeclimate.yml000066400000000000000000000003041466311112200154360ustar00rootroot00000000000000engines: pep8: enabled: true fixme: enabled: true radon: enabled: true checks: Complexity: enabled: false ratings: paths: - "**.py" exclude_paths: - tests/ alot-0.11/.github/000077500000000000000000000000001466311112200137275ustar00rootroot00000000000000alot-0.11/.github/ISSUE_TEMPLATE/000077500000000000000000000000001466311112200161125ustar00rootroot00000000000000alot-0.11/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000013111466311112200206000ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve --- Before you submit a bug report, please make sure that the issue still exists on the master branch! **Describe the bug** A clear and concise description of what the bug is. **Software Versions** - Python version: - Notmuch version: [if relevant] - Alot version: **To Reproduce** Steps to reproduce the behaviour: 1. open search buffer 2. `call hooks.boom()` 3. See error **Error Log** Please include all error information from the log file. - To get a verbose log file run alot as `alot -d debug -l logfile`. - Make sure the log you post contains no sensitive information. - Please don't submit the complete log but only the relevant parts. alot-0.11/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000010601466311112200216340ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. alot-0.11/.github/ci-reporter.yml000066400000000000000000000004771466311112200167150ustar00rootroot00000000000000# Set to false to create a new comment instead of updating the app's first one updateComment: true # Use a custom string, or set to false to disable before: "Thanks for the PR so far! Unfortunately, the [ build]() is failing as of . Here's the output:" # Use a custom string, or set to false to disable after: false alot-0.11/.github/workflows/000077500000000000000000000000001466311112200157645ustar00rootroot00000000000000alot-0.11/.github/workflows/check.yml000066400000000000000000000034361466311112200175720ustar00rootroot00000000000000name: Run code checks with different build tools on: - push - pull_request jobs: python-build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.8", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install the build module run: pip install build - name: Build the alot package run: python3 -m build nix-flake: runs-on: ubuntu-latest strategy: fail-fast: false matrix: derivation: - default - alot.doc - alot.man steps: - name: Install Nix uses: cachix/install-nix-action@v22 - uses: actions/checkout@v4 - name: Build the ${{ matrix.derivation }} derivation run: 'nix build --print-build-logs .\#${{ matrix.derivation }}' generated-docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Remove and mock problematic dependencies run: | sed -i '/gpg/d;/notmuch2/d' pyproject.toml touch gpg.py echo NullPointerError = NotmuchError = None > notmuch2.py pip install . git checkout pyproject.toml - name: Regenerate all generated docs # We run "true" instead of "sphinx-build" to speed things up as we are # only interested in the regeneration of the docs. run: make -C docs cleanall html SPHINXBUILD=true - name: Compare the generated docs with the version committed to git run: git diff --exit-code alot-0.11/.github/workflows/test.yml000066400000000000000000000054741466311112200175000ustar00rootroot00000000000000name: Run tests on: - push - pull_request jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies for the gpg and notmuch python package run: | set -e sudo apt-get update sudo apt-get install -y --no-install-recommends \ libgpgme-dev libxapian-dev libgmime-3.0-dev libtalloc-dev swig env: DEBIAN_FRONTEND: noninteractive - name: clone the notmuch repository run: git clone --depth 1 https://git.notmuchmail.org/git/notmuch notmuch - name: build the notmuch bindings run: | set -e # Make and install the library. ./configure --without-bash-completion \ --without-api-docs \ --without-emacs \ --without-desktop \ --without-ruby \ --without-zsh-completion make sudo make install working-directory: notmuch - name: Install notmuch python bindings run: pip install . working-directory: notmuch/bindings/python-cffi - name: "Workaround for issue #1630: mock gpg instead of installing it" # FIXME: It is very difficult to install a recent gpg package in ci. # This is related to issue 1630 (see # https://github.com/pazz/alot/issues/1630#issuecomment-1938174029 and # onwards). The problem was introduced in c1137ea9: the gpg # dependency is required with version > 1.10.0 and such a version is # not currently available on PyPI but must be build from hand. run: | # do not install gpg with pip sed -i /gpg/d pyproject.toml # mock a minimal structure of the gpg module mkdir -p gpg/constants echo from . import constants > gpg/__init__.py echo from . import validity > gpg/constants/__init__.py echo FULL = 4 > gpg/constants/validity.py # skip all tests that depend on gpg sed -i '/import unittest/araise unittest.SkipTest("gpg based test do not work in CI")\n' tests/test_crypto.py sed -i 's/\( *\)def setUpClass.*/&\n\1 raise unittest.SkipTest("gpg based test do not work in CI")\n/' tests/db/test_utils.py sed -i 's/\( *\)async def test_no_spawn_no_stdin_attached.*/\1@unittest.skip\n&/' tests/commands/test_global.py - name: Install dependencies run: | pip install . - name: Run tests run: python3 -m unittest --verbose alot-0.11/.gitignore000066400000000000000000000002571466311112200143630ustar00rootroot00000000000000*.py[co] *.log *.swp *.egg-info/ /bin /build /dist /include /lib /lib64 /MANIFEST .gdb_history docs/build docs/source/configuration/*table.rst tags .eggs __pycache__ result*/ alot-0.11/CONTRIBUTING.md000066400000000000000000000051431466311112200146230ustar00rootroot00000000000000Getting Involved ---------------- Development is coordinated almost entirely on our [Github] page. For quick help or a friendly chat you are welcome to pop into our IRC channel [`#alot` on Libera.chat][Libera]. Bug reports and feature requests ----------------------------------- Are more than welcome via our [issue tracker][Issues]. Before you do, please be sure that * you are using up to date versions of alot and notmuch * your bug/feature is not already being discussed on the [issue tracker][ISSUES] * your feature request is in scope of the project. Specifically *not in scope are features related to contact management, fetching and sending email*. Licensing --------- Alot is licensed under the [GNU GPLv3+][GPL3] and all code contributions will be covered by this license. You will retain your copyright on all code you write. By contributing you have agreed that you have the right to contribute the code you have written, that the code you are contributing is owned by you or licensed with a compatible license allowing it to be added to alot, and that your employer or educational institution either does not or cannot claim the copyright on any code you write, or that they have given you a waiver to contribute to alot. Pull Requests --------------- You are welcome to submit changes as pull requests on github. This will trigger consistency checks and unit tests to be run on our CI system. To ensure timely and painless reviews please keep the following in mind. * Follow [PEP8]. You can use [automatic tools][pycodestyle] to verify your code. * Document your code! We want readable and well documented code. Please use [sphinx] directives to document the signatures of your methods. For new features also update the user manual in `docs/source/usage` accordingly. * Unit tests: Make sure your changes don't break any existing tests (to check locally use `python3 -m unittest`). If you are fixing a bug or adding a new features please provide new tests if possible. * Keep commits simple. Large individual patches are incredibly painful to review properly. Please split your contribution into small, focussed and digestible commits and include [sensible commit messages][commitiquette]. [Github]: https://github.com/pazz/alot [Issues]: https://github.com/pazz/alot/issues [Libera]: https://web.libera.chat/#alot [GPL3]: https://www.gnu.org/licenses/gpl-3.0.en.html [PEP8]: https://www.python.org/dev/peps/pep-0008/ [pycodestyle]:https://github.com/PyCQA/pycodestyle [sphinx]: https://www.sphinx-doc.org/en/master/usage/restructuredtext/field-lists.html [commitiquette]: https://chris.beams.io/posts/git-commit/ alot-0.11/COPYING000066400000000000000000001043711466311112200134300ustar00rootroot00000000000000 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 . alot-0.11/MANIFEST.in000066400000000000000000000002251466311112200141240ustar00rootroot00000000000000include COPYING include NEWS include extra/completion/alot-completion.zsh include extra/alot-mailto.desktop include extra/alot.desktop include tests alot-0.11/NEWS000066400000000000000000000313441466311112200130730ustar00rootroot000000000000000.11: * breaking: hooks file now has to be set in alot's config and defaults to no hooks * info: forwarded mails now set Reference header to include them in original thread * config: new option "thread_unfold_matching" to determine which messages are unfolded initially in thread mode * deps: bump dependency to python-gpg to >0.10 to avoid use of outdated version from pypi * deps: alot now requires python v>=3.8 * improved handling of ANSI escape codes * info: mail paths (sent/drafts etc) now interpret environment variables * lots of fixes relating to configobj, notmuch-config, theming, and the build and CI processes 0.10: * various fixes with mailcap handling * info: alot now depends on the new-style cffi bindings (notmuch2, available under notmuch/bindings/python-cffi) * config: new option search_threads_rebuild_limit (to speed up "move last" in large search buffers) 0.9.1: * focus in search mode is preserved when refreshing * feature: new 'togglemimepart' cmd in thread mode toggles between plain and html alternative * feature: add "togglemimetree" command to thread mode * envelope mode: "unattach" command is now "detach" * feature: respect mailcap entry for text/plain to enrich plaintext parts with ANSI codes 0.9: * feature: interpret ANSI escape codes (e.g.for colours) when displaying messages * config: configure message-id domains for each account * feature: new envelope commands txt2html, html2txt, removehtml * info: updated signature of hooks 'reply_prefix' and 'forward_prefix', now include a named parameter for the message being replied/forwarded 0.8: * Port to python 3. Python 2.x no longer supported * support for notmuch's named queries. This adds a new 'namedqueries' mode and accompanied commands * config: new option replied_tags * config: new option passed_tags * feature: new command "retagprompt" in thread buffers * extra: update zsh completion 0.7: * info: missing html mailcap entry now reported as mail body text * feature: Allow regex special characters in tagstrings * feature: configurable thread mode message indentation * new thread buffer command "indent" (bound to '[' and ']') * config: new option thread_indent_replies * config: new option exclude_tags * config: new option encrypt_to_self * config: update behaviour of encrypt_by_default 0.6: * feature: Add command to reload configuration files in running session * feature: new command "tag" (and friends) in EnvelopeBuffer to add additional tags after sending * feature: Themes can now be loaded from system locations * bug fix: GPG signatures are acutally verified * feature: option to use linewise focussing in thread mode * feature: add support to move to next or previous message matching a notmuch query in a thread buffer * feature: Convert from deprecated pygppme module to upstream gpg wrappers * feature: Verify signatures/decrypt messages in multipart/mixed payloads 0.5: * save command prompt, recipient and sender history across program restarts * new config option: "handle_mouse" to enable interpretation of mouse events * prompt for unsent messages before closing * enable variable interpolation in config file * Add encryption to CC addresses * Add bufferlist, tablist and pyshell subcommands to the command line interface * new hook: "loop_hook", that runs periodically * new config option: "periodic_hook_frequency" to adjust how often to fire "loop_hook" 0.4: * signal: refresh current buffer on SIGUSR1 * signal: exit interface on SIGINT * interpret C-g keybinding in prompts * new config option:encrypt_by_default * new config option: thread_authors_order_by * indicate untrusted PGP signatures in thread view * more flexible account selection for replies 0.3.7: * new config option: msg_summary_hides_threadwide_tags * new config setting: thread_subject * new hook: sanitize_attachment_filename * new hook: exit() * list replies (new command parameter and config setting: auto_replyto_mailinglist) * new config setting for accounts: encrypt_by_default * new config setting for accounts: alias_regexp * new config setting for abooks: shellcommand_external_filtering * switched to setuptools * detached urwidtrees library into separate project (and new dependency) 0.3.6: * implement vim-style "move last" command (bound to G) * fixes in reply/forwarding * add option "--tags" to taglist command to display only a subset of the tags * fix: safely interrumpt a command sequence * use suffix ".eml" for temporary email files when editing * interpret "compose mailto:foo@bar" commands * new "tomorrow" colour theme * Add some Emacs keybindings for prompts 0.3.5: * full support for PGP/MIME [de|en]cryption and signatures * add missing "unattach" command in envelope buffer * honor 'Mail-Followup-To' header and set if for selected mailinglists * better handling of replies to self-sent messages * make auto_remove_unread configurable * rewrite thread buffer * improved global move commands: first/last line, half-page up/down * tree-based movement in threads (first/last reply, next/previous unfolded/sibling, parent) * fold/unfold messages based on query string in thread mode * respect mailcap commands that expect stdin * Support different libmagic APIs * new hooks called before/aftr buffer open/close/focus * new global repeat command 0.3.4: * extra: zsh completion file * thread mode: add "tags" pseudo header to message display * case insensitive matching in Addressbook completion * compose: interpret "attach" pseudo header * compose: set initial message tags * envelope: completion for 'From' * reply/forward: more flexible construction of "From" headers (hello plussing!) * thread mode: added bounce command for direct redirection w/o an envelope buffer * thread mode: more robust "pipeto" command * add config option "prefer_plaintext" * prevent multiple 'index locked' notifications * kill some zombies! (#325) * search mode: bulk tagging * less annoying multi-key bindings * add global "move" command for scriptable cursor movement * support for encrypted outgoing mails using PGP/MIME 0.3.3: * interpret (semicolon separated) sequences of commands * new input handling: allow for binding sequences of keypresses * add ability to overwrite default bindings * remove tempfiles (email drafts) as late as possible for better error recovery * confirmation prompt when closing unsent envelopes * prevent accidental double sendout of envelopes * fix focus placement after tagcommand on last entry in search buffer * new command 'buffer' that can directly jump to buffer with given number * extra: sup theme * fix tagstring sorting in taglist buffer * update docs * lots of internal cleanups * search buffer theming fixes (alignment of threadline parts) * fix help box theming * comma-separate virtual "Tags" header added before printing mails * fix pipeto command for interactive (foreground) shell commands * handle possible errors occurring while saving mails * indicate (yet uninterpreted) input queue in the status bar * handle python exceptions that occur during 'call' command 0.3.2: * fix bad GPG signatures for mails with attachments * new theme-files + tags section syntax * re-introduce "highlighting" of thread lines in search mode * new global command "call" to directly call and bind python commands * add new buffers to direct neighbourhood of current one * fix sanitize --spawn for X11-less use * add new hook 'touch_external_cmdlist' * make statusline configurable * fix update result count after tag operations in search mode * add config options and hooks for reply/forward subject generation * add config options and hook for quoting messages in replies/forwards * allow True/False/None values for boolean command parameters * new config option "attachment_prefix" * various small fixes for libmagic, header encoding and decoding 0.3.1: * use separate database for each write-queue entry when flushing * fix behaviour of editor spawning * fix opening of attachments in thread buffer * fix pre_edit_translate hook * fix opening of attachments without filename Content-Disposition parm * clean up and complete theming (bindings help/envelope/mainframe body) * fix datetime decoding issues * fix abort commands on pre-hook exceptions * fix correct default sendmail command to 'sendmail -t' * use '> ' instead of '>' to quote in replies/fwds * fix path completer wrt spaces in paths * fix UI when no buffers are open * fix issue with buffer type changing between flushes * support multiple addresses per abook contact when using 'abook' completer * smarter timestamp pretty printer * new hook 'timestamp_format' * merge multiple cc/to headers into one when displaying * respect NOTMUCH_CONFIG env var * smarter parsing of edited header values * allow for singleton lists without trailing comma in config * fix reverse-date sorted content in threadline if displayed * emacs-style C-a and C-E in prompts * added ability to sign outgoing mails via PGP/MIME 0.3: * revised config syntax! * config file validation, better feedback on malformed configs * themes read from separate files in their own (validated) syntax * complete mailcap compatibility * user manual * direct addressbook type that parses `abook`s contacts * completion for multiple recipients via AbooksCompleter * completion for optional command parameter * generate and set a Message-ID header when constructing mails * add User-Agent header by default * add sent and saved draft mails to the notmuch index and add custom tags * guess file encodings with libmagic * new thread mode command: "remove" to delete messages from the index * new thread mode command: "editnew" e.g. to continue drafts (bound to 'n') * new thread mode command: "togglesource" to display raw messages (bound to 'h') * virtual "Tags" header for print and pipeto commands via --add_tags parameter * much improved pipeto command in thread mode * --spawn parameter for reply,forward,compose,editnew in thread mode * --no-flush parameter for delayed flushing in tag,untag,toggletags commands * make "signature as attachment" configurable; --omit_signature parameter for compose * new envelope command: "save" to save as draft (bound to 'P') * --no-refocus and --spawn parameter for edit in envelope mode * header key completion for set/unset in envelope buffer * "Me" substitution for ones own name/address in authors string * new search mode command and search argument: "sort" * renamed search mode command 'toggletag' to "toggletags" * new search mode commands: "tag" and "untag" * custom tagstring representation: hiding, substitution, colours, multi-matching 0.21: * avoid traceback infos from getting written on top of the ui * new "--help" output, autogenerated manpage * version string extracted from git for cli option "--version" * command line subcommands: compose and search * properly display multiple headers with the same key * envelope.set option "--append" * more detailed CUSTOMIZE docs * multiple fixes for the envelope buffer * exit on closing of last buffer is now optional * die gracefully when config parsing fails * random bugfixes in the ui * determine attachments via the "Content-Disposition" header * nicer alignment for messages in thread buffers * deal with external commands as lists of strings instead of strings * better filetype detection in attachments via magic numbers * errbacks and correct calling of post-hooks for deferred commands * add packaging info for debian * envelope.headers stores lists of values for each key now * default binding: 's' to 'toggletag unread' in search buffers 0.20: * extensive API docs * fancy shortening for authors list * moved default location for config to ~/.config/alot/config * message templates * hooks for pre/post editing and RE/FWD quotestrings * recipient completion gives priority to abook of sender account * smarter in-string-tab completion * added ability to pipe messages/treads to custom shell commands * initial command configurable in config file * non-blocking prompt/choice (new syntax for prompts!) * usage help for every command * bindings help * tons of fixes 0.11: This minor release is mostly bug fixes and some small features. I wanted to release a more stable and usable version before I start hacking on a thread view rewrite. * renamed config section [tag translate] to [tag-translate] * docs: more elaborate API docs, INSTALL and USAGE as markdown in github wiki * more compact header displays in thread view * command-line history (for one session) * editor file encoding is now user configurable * signatures for outgoing mails per account * optional display of message content in search results * config option for strftime formating of timestamps * printing * fix parse multiline headers from edited tempfile * fix reply to unusually formated mails (e.g. no recipient) * fix lots of encoding issues * handle extra wide characters in tag widgets * fixes in ui.prompt * fix storing outgoing mails to sent_box * more liberal header encoding for outgoing mails * use mimetype lib to guess right content-type of attachments alot-0.11/README.md000066400000000000000000000067301466311112200136540ustar00rootroot00000000000000[![Build Status]][ghactions] [![Code Climate][codeclimate-img]][codeclimate] [![Codacy Grade][codacy-grade-img]][codacy-grade] [![Codacy Coverage][codacy-coverage-img]][codacy-coverage] [![Documentation Status][rtfd-img]][rtfd] Alot is a terminal-based mail user agent based on the [notmuch mail indexer][notmuch]. It is written in python using the [urwid][urwid] toolkit and features a modular and command prompt driven interface to provide a full MUA experience as an alternative to the Emacs mode shipped with notmuch. Notable Features ---------------- * multiple accounts for sending mails via sendmail * can spawn terminal windows for asynchronous editing of mails * tab completion and usage help for all commands * contacts completion using customizable lookups commands * user configurable keyboard maps * customizable colour and layout themes * python hooks to react on events and do custom formatting * forward/reply/group-reply of emails * printing/piping of mails and threads * configurable status bar with notification popups * database manager that manages a write queue to the notmuch index * full support for PGP/MIME encryption and signing Installation and Customization ------------------------------ Have a look at the [user manual][docs] for installation notes, advanced usage, customization, hacking guides and [frequently asked questions][FAQ]. We also collect user-contributed hooks and hacks in a [wiki][wiki]. Most of the developers hang out in [`#alot` on the libera.chat IRC server](https://web.libera.chat/#alot), feel free to ask questions or make suggestions there. You are welcome to open issues or pull-requests on the github page. Basic Usage ----------- The arrow keys, `page-up/down`, `j`, `k` and `Space` can be used to move the focus. `Escape` cancels prompts and `Enter` selects. Hit `:` at any time and type in commands to the prompt. The interface shows one buffer at a time, you can use `Tab` and `Shift-Tab` to switch between them, close the current buffer with `d` and list them all with `;`. The buffer type or *mode* (displayed at the bottom left) determines which prompt commands are available. Usage information on any command can be listed by typing `help YOURCOMMAND` to the prompt; The key bindings for the current mode are listed upon pressing `?`. See the [manual][docs] for more usage info. [notmuch]: https://notmuchmail.org/ [urwid]: https://excess.org/urwid/ [docs]: https://alot.rtfd.org [wiki]: https://github.com/pazz/alot/wiki [FAQ]: https://alot.readthedocs.io/en/latest/faq.html [features]: https://github.com/pazz/alot/issues?labels=feature [Build Status]: https://github.com/pazz/alot/actions/workflows/check.yml/badge.svg [ghactions]: https://github.com/pazz/alot/actions [codacy-coverage]: https://www.codacy.com/app/patricktotzke/alot?utm_source=github.com&utm_medium=referral&utm_content=pazz/alot&utm_campaign=Badge_Coverage [codacy-coverage-img]: https://api.codacy.com/project/badge/Coverage/fa7c4a567cd546568a12e88c57f9dbd6 [codacy-grade]: https://www.codacy.com/app/patricktotzke/alot?utm_source=github.com&utm_medium=referral&utm_content=pazz/alot&utm_campaign=Badge_Grade [codacy-grade-img]: https://api.codacy.com/project/badge/Grade/fa7c4a567cd546568a12e88c57f9dbd6 [codeclimate-img]: https://codeclimate.com/github/pazz/alot/badges/gpa.svg [codeclimate]: https://codeclimate.com/github/pazz/alot [rtfd-img]: https://readthedocs.org/projects/alot/badge/?version=latest [rtfd]: https://alot.readthedocs.io/en/latest/ alot-0.11/alot/000077500000000000000000000000001466311112200133265ustar00rootroot00000000000000alot-0.11/alot/__init__.py000066400000000000000000000004261466311112200154410ustar00rootroot00000000000000from importlib.metadata import version, PackageNotFoundError # this requires python >=3.8 try: __version__ = version("alot") except PackageNotFoundError: # package is not installed pass __productname__ = 'alot' __description__ = "Terminal MUA using notmuch mail" alot-0.11/alot/__main__.py000066400000000000000000000135111466311112200154210ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import argparse import locale import logging import os import sys import alot from alot.settings.const import settings from alot.settings.errors import ConfigError from alot.helper import get_xdg_env, get_notmuch_config_path from alot.db.manager import DBManager from alot.ui import UI from alot.commands import * from alot.commands import CommandParseError, COMMANDS from alot.utils import argparse as cargparse from twisted.internet import asyncioreactor asyncioreactor.install() _SUBCOMMANDS = ['search', 'compose', 'bufferlist', 'taglist', 'namedqueries', 'pyshell'] def parser(): """Parse command line arguments, validate them, and return them.""" parser = argparse.ArgumentParser(add_help=False) parser.add_argument('-r', '--read-only', action='store_true', help='open notmuch database in read-only mode') parser.add_argument('-c', '--config', metavar='FILENAME', action=cargparse.ValidatedStoreAction, validator=cargparse.require_file, help='configuration file') parser.add_argument('-n', '--notmuch-config', metavar='FILENAME', default=get_notmuch_config_path(), action=cargparse.ValidatedStoreAction, validator=cargparse.require_file, help='notmuch configuration file') parser.add_argument('-C', '--colour-mode', metavar='COLOURS', choices=(1, 16, 256), type=int, help='number of colours to use') parser.add_argument('-p', '--mailindex-path', metavar='PATH', action=cargparse.ValidatedStoreAction, validator=cargparse.require_dir, help='path to notmuch index') parser.add_argument('-d', '--debug-level', metavar='LEVEL', default='info', choices=('debug', 'info', 'warning', 'error'), help='debug level [default: %(default)s]') parser.add_argument('-l', '--logfile', metavar='FILENAME', default='/dev/null', action=cargparse.ValidatedStoreAction, validator=cargparse.optional_file_like, help='log file [default: %(default)s]') parser.add_argument('-h', '--help', action='help', help='display this help and exit') parser.add_argument('-v', '--version', action='version', version=alot.__version__, help='output version information and exit') # We will handle the subcommands in a separate run of argparse as argparse # does not support optional subcommands until now. parser.add_argument('command', nargs=argparse.REMAINDER, help='possible subcommands are {}'.format( ', '.join(_SUBCOMMANDS))) options = parser.parse_args() if options.command: # We have a command after the initial options so we also parse that. # But we just use the parser that is already defined for the internal # command that will back this subcommand. parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest='subcommand') for subcommand in _SUBCOMMANDS: subparsers.add_parser(subcommand, parents=[COMMANDS['global'][subcommand][1]]) command = parser.parse_args(options.command) else: command = None return options, command def main(): """The main entry point to alot. It parses the command line and prepares for the user interface main loop to run.""" options, command = parser() # locale locale.setlocale(locale.LC_ALL, '') # logging root_logger = logging.getLogger() for log_handler in root_logger.handlers: root_logger.removeHandler(log_handler) root_logger = None numeric_loglevel = getattr(logging, options.debug_level.upper(), None) logformat = '%(levelname)s:%(module)s:%(message)s' logging.basicConfig(level=numeric_loglevel, filename=options.logfile, filemode='w', format=logformat) # locate alot config files cpath = options.config if options.config is None: xdg_dir = get_xdg_env('XDG_CONFIG_HOME', os.path.expanduser('~/.config')) alotconfig = os.path.join(xdg_dir, 'alot', 'config') if os.path.exists(alotconfig): cpath = alotconfig try: settings.read_config(cpath) settings.read_notmuch_config(options.notmuch_config) except (ConfigError, OSError, IOError) as e: print('Error when parsing a config file. ' 'See log for potential details.') sys.exit(e) # store options given by config swiches to the settingsManager: if options.colour_mode: settings.set('colourmode', options.colour_mode) # get ourselves a database manager indexpath = settings.get_notmuch_setting('database', 'path') indexpath = options.mailindex_path or indexpath dbman = DBManager(path=indexpath, ro=options.read_only, config=options.notmuch_config) # determine what to do if command is None: try: cmdstring = settings.get('initial_command') except CommandParseError as err: sys.exit(err) elif command.subcommand in _SUBCOMMANDS: cmdstring = ' '.join(options.command) # set up and start interface UI(dbman, cmdstring) # run the exit hook exit_hook = settings.get_hook('exit') if exit_hook is not None: exit_hook() if __name__ == "__main__": main() alot-0.11/alot/account.py000066400000000000000000000336361466311112200153470ustar00rootroot00000000000000# encoding=utf-8 # Copyright (C) Patrick Totzke # Copyright © 2017 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import abc import glob import logging import mailbox import operator import os import re from .helper import call_cmd_async from .helper import split_commandstring class Address: """A class that represents an email address. This class implements a number of RFC requirements (as explained in detail below) specifically in the comparison of email addresses to each other. This class abstracts the requirements of RFC 5321 § 2.4 on the user name portion of the email: local-part of a mailbox MUST BE treated as case sensitive. Therefore, SMTP implementations MUST take care to preserve the case of mailbox local-parts. In particular, for some hosts, the user "smith" is different from the user "Smith". However, exploiting the case sensitivity of mailbox local-parts impedes interoperability and is discouraged. Mailbox domains follow normal DNS rules and are hence not case sensitive. This is complicated by § 2.3.11 of the same RFC: The standard mailbox naming convention is defined to be "local-part@domain"; contemporary usage permits a much broader set of applications than simple "user names". Consequently, and due to a long history of problems when intermediate hosts have attempted to optimize transport by modifying them, the local-part MUST be interpreted and assigned semantics only by the host specified in the domain part of the address. And also the restrictions that RFC 1035 § 3.1 places on the domain name: Name servers and resolvers must compare [domains] in a case-insensitive manner Because of RFC 6531 § 3.2, we take special care to ensure that unicode names will work correctly: An SMTP server that announces the SMTPUTF8 extension MUST be prepared to accept a UTF-8 string [RFC3629] in any position in which RFC 5321 specifies that a can appear. Although the characters in the are permitted to contain non-ASCII characters, the actual parsing of the and the delimiters used are unchanged from the base email specification [RFC5321] What this means is that the username can be either case-insensitive or not, but only the receiving SMTP server can know what it's own rules are. The consensus is that the vast majority (all?) of the SMTP servers in modern usage treat user names as case-insensitve. Therefore we also, by default, treat the user name as case insenstive. :param str user: The "user name" portion of the address. :param str domain: The domain name portion of the address. :param bool case_sensitive: If False (the default) the user name portion of the address will be compared to the other user name portion without regard to case. If True then it will. """ def __init__(self, user, domain, case_sensitive=False): assert isinstance(user, str), 'Username must be str' assert isinstance(domain, str), 'Domain name must be str' self.username = user self.domainname = domain self.case_sensitive = case_sensitive @classmethod def from_string(cls, address, case_sensitive=False): """Alternate constructor for building from a string. :param str address: An email address in @ form :param bool case_sensitive: passed directly to the constructor argument of the same name. :returns: An account from the given arguments :rtype: :class:`Account` """ assert isinstance(address, str), 'address must be str' username, domainname = address.split('@') return cls(username, domainname, case_sensitive=case_sensitive) def __repr__(self): return 'Address({!r}, {!r}, case_sensitive={})'.format( self.username, self.domainname, str(self.case_sensitive)) def __str__(self): return '{}@{}'.format(self.username, self.domainname) def __cmp(self, other, comparitor): """Shared helper for rich comparison operators. This allows the comparison operators to be relatively simple and share the complex logic. If the username is not considered case sensitive then lower the username of both self and the other, and handle that the other can be either another :class:`~alot.account.Address`, or a `str` instance. :param other: The other address to compare against :type other: str or ~alot.account.Address :param callable comparitor: A function with the a signature (str, str) -> bool that will compare the two instance. The intention is to use functions from the operator module. """ if isinstance(other, str): try: ouser, odomain = other.split('@') except ValueError: ouser, odomain = '', '' else: ouser = other.username odomain = other.domainname if not self.case_sensitive: ouser = ouser.lower() username = self.username.lower() else: username = self.username return (comparitor(username, ouser) and comparitor(self.domainname.lower(), odomain.lower())) def __eq__(self, other): if not isinstance(other, (Address, str)): raise TypeError('Address must be compared to Address or str') return self.__cmp(other, operator.eq) def __ne__(self, other): if not isinstance(other, (Address, str)): raise TypeError('Address must be compared to Address or str') # != is the only rich comparitor that cannot be implemented using 'and' # in self.__cmp, so it's implemented as not ==. return not self.__cmp(other, operator.eq) def __hash__(self): return hash((self.username.lower(), self.domainname.lower(), self.case_sensitive)) class SendingMailFailed(RuntimeError): pass class StoreMailError(Exception): pass class Account: """ Datastructure that represents an email account. It manages this account's settings, can send and store mails to maildirs (drafts/send). .. note:: This is an abstract class that leaves :meth:`send_mail` unspecified. See :class:`SendmailAccount` for a subclass that uses a sendmail command to send out mails. """ __metaclass__ = abc.ABCMeta address = None """this accounts main email address""" aliases = [] """list of alternative addresses""" alias_regexp = "" """regex matching alternative addresses""" realname = None """real name used to format from-headers""" encrypt_to_self = None """encrypt outgoing encrypted emails to this account's private key""" gpg_key = None """gpg fingerprint for this account's private key""" signature = None """signature to append to outgoing mails""" signature_filename = None """filename of signature file in attachment""" signature_as_attachment = None """attach signature file instead of appending its content to body text""" abook = None """addressbook (:class:`addressbook.AddressBook`) managing this accounts contacts""" def __init__(self, address=None, aliases=None, alias_regexp=None, realname=None, gpg_key=None, signature=None, signature_filename=None, signature_as_attachment=False, sent_box=None, sent_tags=None, draft_box=None, draft_tags=None, replied_tags=None, passed_tags=None, abook=None, sign_by_default=False, encrypt_by_default="none", encrypt_to_self=None, message_id_domain=None, case_sensitive_username=False, **_): self.address = Address.from_string( address, case_sensitive=case_sensitive_username) self.aliases = [ Address.from_string(a, case_sensitive=case_sensitive_username) for a in (aliases or [])] self.alias_regexp = alias_regexp self.realname = realname self.encrypt_to_self = encrypt_to_self self.gpg_key = gpg_key self.signature = signature self.signature_filename = signature_filename self.signature_as_attachment = signature_as_attachment self.sign_by_default = sign_by_default self.sent_box = sent_box self.sent_tags = sent_tags self.draft_box = draft_box self.draft_tags = draft_tags self.replied_tags = replied_tags self.passed_tags = passed_tags self.abook = abook self.message_id_domain = message_id_domain # Handle encrypt_by_default in an backwards compatible way. The # logging info call can later be upgraded to warning or error. encrypt_by_default = encrypt_by_default.lower() msg = "Deprecation warning: The format for the encrypt_by_default " \ "option changed. Please use 'none', 'all' or 'trusted'." if encrypt_by_default in ("true", "yes", "1"): encrypt_by_default = "all" logging.info(msg) elif encrypt_by_default in ("false", "no", "0"): encrypt_by_default = "none" logging.info(msg) self.encrypt_by_default = encrypt_by_default # cache alias_regexp regexes if self.alias_regexp != "": self._alias_regexp = re.compile( '^' + str(self.alias_regexp) + '$', flags=0 if case_sensitive_username else re.IGNORECASE) def matches_address(self, address): """returns whether this account knows about an email address :param str address: address to look up :rtype: bool """ if self.address == address: return True for alias in self.aliases: if alias == address: return True if self._alias_regexp and self._alias_regexp.match(address): return True return False @staticmethod def store_mail(mbx, mail): """ stores given mail in mailbox. If mailbox is maildir, set the S-flag and return path to newly added mail. Oherwise this will return `None`. :param mbx: mailbox to use :type mbx: :class:`mailbox.Mailbox` :param mail: the mail to store :type mail: :class:`email.message.Message` or str :returns: absolute path of mail-file for Maildir or None if mail was successfully stored :rtype: str or None :raises: StoreMailError """ if not isinstance(mbx, mailbox.Mailbox): logging.debug('Not a mailbox') return False mbx.lock() if isinstance(mbx, mailbox.Maildir): logging.debug('Maildir') msg = mailbox.MaildirMessage(mail) msg.set_flags('S') else: logging.debug('no Maildir') msg = mailbox.Message(mail) try: message_id = mbx.add(msg) mbx.flush() mbx.unlock() logging.debug('got mailbox msg id : %s', message_id) except Exception as e: raise StoreMailError(e) path = None # add new Maildir message to index and add tags if isinstance(mbx, mailbox.Maildir): # this is a dirty hack to get the path to the newly added file # I wish the mailbox module were more helpful... plist = glob.glob1(os.path.join(mbx._path, 'new'), message_id + '*') if plist: path = os.path.join(mbx._path, 'new', plist[0]) logging.debug('path of saved msg: %s', path) return path def store_sent_mail(self, mail): """ stores mail (:class:`email.message.Message` or str) in send-store if :attr:`sent_box` is set. """ if self.sent_box is not None: return self.store_mail(self.sent_box, mail) def store_draft_mail(self, mail): """ stores mail (:class:`email.message.Message` or str) as draft if :attr:`draft_box` is set. """ if self.draft_box is not None: return self.store_mail(self.draft_box, mail) @abc.abstractmethod async def send_mail(self, mail): """ sends given mail :param mail: the mail to send :type mail: :class:`email.message.Message` or string :raises SendingMailFailed: if sending fails """ pass class SendmailAccount(Account): """:class:`Account` that pipes a message to a `sendmail` shell command for sending""" def __init__(self, cmd, **kwargs): """ :param cmd: sendmail command to use for this account :type cmd: str """ super(SendmailAccount, self).__init__(**kwargs) self.cmd = cmd async def send_mail(self, mail): """Pipe the given mail to the configured sendmail command. Display a short message on success or a notification on error. :param mail: the mail to send out :type mail: :class:`email.message.Message` or string :raises: class:`SendingMailFailed` if sending failes """ cmdlist = split_commandstring(self.cmd) try: # make sure self.mail is a string out, err, code = await call_cmd_async(cmdlist, stdin=str(mail)) if code != 0: msg = 'The sendmail command {} returned with code {}{}'.format( self.cmd, code, ':\n' + err.strip() if err else '.') raise Exception(msg) except Exception as e: logging.error(str(e)) raise SendingMailFailed(str(e)) logging.info('sent mail successfully') logging.info(out) alot-0.11/alot/addressbook/000077500000000000000000000000001466311112200156265ustar00rootroot00000000000000alot-0.11/alot/addressbook/__init__.py000066400000000000000000000022401466311112200177350ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import re import abc class AddressbookError(Exception): pass class AddressBook: """can look up email addresses and realnames for contacts. .. note:: This is an abstract class that leaves :meth:`get_contacts` unspecified. See :class:`AbookAddressBook` and :class:`ExternalAddressbook` for implementations. """ __metaclass__ = abc.ABCMeta def __init__(self, ignorecase=True): self.reflags = re.IGNORECASE if ignorecase else 0 @abc.abstractmethod def get_contacts(self): # pragma no cover """list all contacts tuples in this abook as (name, email) tuples""" return [] def lookup(self, query=''): """looks up all contacts where name or address match query""" res = [] query = re.compile('.*%s.*' % re.escape(query), self.reflags) for name, email in self.get_contacts(): if query.match(name) or query.match(email): res.append((name, email)) return res alot-0.11/alot/addressbook/abook.py000066400000000000000000000021511466311112200172720ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import os from . import AddressBook from ..settings.utils import read_config class AbookAddressBook(AddressBook): """:class:`AddressBook` that parses abook's config/database files""" def __init__(self, path='~/.abook/addressbook', **kwargs): """ :param path: path to abook addressbook file :type path: str """ AddressBook.__init__(self, **kwargs) DEFAULTSPATH = os.path.join(os.path.dirname(__file__), '..', 'defaults') self._spec = os.path.join(DEFAULTSPATH, 'abook_contacts.spec') path = os.path.expanduser(path) self._config = read_config(path, self._spec) del self._config['format'] def get_contacts(self): c = self._config res = [] for id in c.sections: for email in c[id]['email']: if email: res.append((c[id]['name'], email)) return res alot-0.11/alot/addressbook/external.py000066400000000000000000000064111466311112200200240ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import re from ..helper import call_cmd from ..helper import split_commandstring from . import AddressBook, AddressbookError import logging class ExternalAddressbook(AddressBook): """:class:`AddressBook` that parses a shell command's output""" def __init__(self, commandline, regex, reflags=0, external_filtering=True, **kwargs): """ :param commandline: commandline :type commandline: str :param regex: regular expression used to match contacts in `commands` output to stdout. Must define subparts named "email" and "name". :type regex: str :param reflags: flags to use with regular expression. Use the constants defined in :mod:`re` here (`re.IGNORECASE` etc.) The default (inherited) value is set via the `ignorecase` config option (defaults to `re.IGNORECASE`) Setting a value here will replace this. :type reflags: str :param external_filtering: if True the command is fired with the given search string as parameter and the result is not filtered further. If set to False, the command is fired without additional parameters and the result list is filtered according to the search string. :type external_filtering: bool """ AddressBook.__init__(self, **kwargs) self.commandline = commandline self.regex = regex if reflags: self.reflags = reflags self.external_filtering = external_filtering def get_contacts(self): return self._call_and_parse(self.commandline) def lookup(self, prefix): # pragma: no cover if self.external_filtering: return self._call_and_parse(self.commandline + " " + prefix) else: return AddressBook.lookup(self, prefix) def _call_and_parse(self, commandline): cmdlist = split_commandstring(commandline) resultstring, errmsg, retval = call_cmd(cmdlist) if retval != 0: msg = 'abook command "%s" returned with ' % commandline msg += 'return code %d' % retval if errmsg: msg += ':\n%s' % errmsg raise AddressbookError(msg) if not resultstring: logging.debug("No contacts in address book (empty string)") return [] lines = resultstring.splitlines() res = [] logging.debug("Apply %s on %d results" % (self.regex, len(lines))) for l in lines: m = re.match(self.regex, l, self.reflags) if m: info = m.groupdict() if 'email' in info and 'name' in info: email = info['email'].strip() name = info['name'] res.append((name, email)) logging.debug("New match name=%s mail=%s" % (name, email)) return res alot-0.11/alot/buffers/000077500000000000000000000000001466311112200147625ustar00rootroot00000000000000alot-0.11/alot/buffers/__init__.py000066400000000000000000000006521466311112200170760ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from .buffer import Buffer from .bufferlist import BufferlistBuffer from .envelope import EnvelopeBuffer from .search import SearchBuffer from .taglist import TagListBuffer from .thread import ThreadBuffer from .namedqueries import NamedQueriesBuffer alot-0.11/alot/buffers/buffer.py000066400000000000000000000020201466311112200165770ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file class Buffer: """Abstract base class for buffers.""" modename = None # mode identifier for subclasses def __init__(self, ui, widget): self.ui = ui self.body = widget def __str__(self): return '[%s]' % self.modename def render(self, size, focus=False): return self.body.render(size, focus) def selectable(self): return self.body.selectable() def rebuild(self): """tells the buffer to (re)construct its visible content.""" pass def keypress(self, size, key): return self.body.keypress(size, key) def cleanup(self): """called before buffer is closed""" pass def get_info(self): """ return dict of meta infos about this buffer. This can be requested to be displayed in the statusbar. """ return {} alot-0.11/alot/buffers/bufferlist.py000066400000000000000000000045671466311112200175150ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import urwid from .buffer import Buffer from ..widgets.bufferlist import BufferlineWidget from ..settings.const import settings class BufferlistBuffer(Buffer): """lists all active buffers""" modename = 'bufferlist' def __init__(self, ui, filtfun=lambda x: x): self.filtfun = filtfun self.ui = ui self.isinitialized = False self.rebuild() Buffer.__init__(self, ui, self.body) def index_of(self, b): """ returns the index of :class:`Buffer` `b` in the global list of active buffers. """ return self.ui.buffers.index(b) def rebuild(self): if self.isinitialized: focusposition = self.bufferlist.get_focus()[1] else: focusposition = 0 self.isinitialized = True lines = list() displayedbuffers = [b for b in self.ui.buffers if self.filtfun(b)] for (num, b) in enumerate(displayedbuffers): line = BufferlineWidget(b) if (num % 2) == 0: attr = settings.get_theming_attribute('bufferlist', 'line_even') else: attr = settings.get_theming_attribute('bufferlist', 'line_odd') focus_att = settings.get_theming_attribute('bufferlist', 'line_focus') buf = urwid.AttrMap(line, attr, focus_att) num = urwid.Text('%3d:' % self.index_of(b)) lines.append(urwid.Columns([('fixed', 4, num), buf])) self.bufferlist = urwid.ListBox(urwid.SimpleListWalker(lines)) num_buffers = len(displayedbuffers) if focusposition is not None and num_buffers > 0: self.bufferlist.set_focus(focusposition % num_buffers) self.body = self.bufferlist def get_selected_buffer(self): """returns currently selected :class:`Buffer` element from list""" linewidget, _ = self.bufferlist.get_focus() bufferlinewidget = linewidget.get_focus().original_widget return bufferlinewidget.get_buffer() def focus_first(self): """Focus the first line in the buffer list.""" self.body.set_focus(0) alot-0.11/alot/buffers/envelope.py000066400000000000000000000106521466311112200171550ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import urwid import os from .buffer import Buffer from ..settings.const import settings from ..widgets.globals import HeadersList from ..widgets.globals import AttachmentWidget from ..helper import shorten_author_string from ..helper import string_sanitize from ..db.utils import render_part from email.mime.text import MIMEText class EnvelopeBuffer(Buffer): """message composition mode""" modename = 'envelope' def __init__(self, ui, envelope): self.ui = ui self.envelope = envelope self.all_headers = False self.displaypart = "plaintext" self.rebuild() Buffer.__init__(self, ui, self.body) def __str__(self): to = self.envelope.get('To', fallback='unset') return '[envelope] to: %s' % (shorten_author_string(to, 400)) def get_info(self): info = {} info['to'] = self.envelope.get('To', fallback='unset') info['displaypart'] = self.displaypart return info def cleanup(self): if self.envelope.tmpfile: os.unlink(self.envelope.tmpfile.name) def rebuild(self): displayed_widgets = [] hidden = settings.get('envelope_headers_blacklist') # build lines lines = [] for (k, vlist) in self.envelope.headers.items(): if (k not in hidden) or self.all_headers: for value in vlist: lines.append((k, value)) # sign/encrypt lines if self.envelope.sign: description = 'Yes' sign_key = self.envelope.sign_key if sign_key is not None and len(sign_key.subkeys) > 0: description += ', with key ' + sign_key.uids[0].uid lines.append(('GPG sign', description)) if self.envelope.encrypt: description = 'Yes' encrypt_keys = self.envelope.encrypt_keys.values() if len(encrypt_keys) == 1: description += ', with key ' elif len(encrypt_keys) > 1: description += ', with keys ' key_ids = [] for key in encrypt_keys: if key is not None and key.subkeys: key_ids.append(key.uids[0].uid) description += ', '.join(key_ids) lines.append(('GPG encrypt', description)) if self.envelope.tags: lines.append(('Tags', ','.join(self.envelope.tags))) # add header list widget iff header values exists if lines: key_att = settings.get_theming_attribute('envelope', 'header_key') value_att = settings.get_theming_attribute('envelope', 'header_value') gaps_att = settings.get_theming_attribute('envelope', 'header') self.header_wgt = HeadersList(lines, key_att, value_att, gaps_att) displayed_widgets.append(self.header_wgt) # display attachments lines = [] for a in self.envelope.attachments: lines.append(AttachmentWidget(a, selectable=False)) if lines: self.attachment_wgt = urwid.Pile(lines) displayed_widgets.append(self.attachment_wgt) # message body txt = self._find_body_text() self.body_wgt = urwid.Text(string_sanitize(txt)) displayed_widgets.append(self.body_wgt) self.body = urwid.ListBox(displayed_widgets) def toggle_all_headers(self): """Toggle visibility of all envelope headers.""" self.all_headers = not self.all_headers self.rebuild() def _find_body_text(self): txt = "no such part!" if self.displaypart == "html": htmlpart = MIMEText(self.envelope.body_html, 'html', 'utf-8') txt = render_part(htmlpart) elif self.displaypart == "src": txt = self.envelope.body_html elif self.displaypart == "plaintext": txt = self.envelope.body_txt return txt def set_displaypart(self, part): """Update the view to display body part (plaintext, html, src). ..note:: This assumes that selv.envelope.body_html exists in case the requested part is 'html' or 'src'! """ self.displaypart = part txt = self._find_body_text() self.body_wgt.set_text(txt) alot-0.11/alot/buffers/namedqueries.py000066400000000000000000000050151466311112200200170ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import urwid from .buffer import Buffer from ..settings.const import settings from ..widgets.namedqueries import QuerylineWidget class NamedQueriesBuffer(Buffer): """lists named queries present in the notmuch database""" modename = 'namedqueries' def __init__(self, ui, filtfun): self.ui = ui self.filtfun = filtfun self.isinitialized = False self.querylist = None self.rebuild() Buffer.__init__(self, ui, self.body) def rebuild(self): self.queries = self.ui.dbman.get_named_queries() if self.isinitialized: focusposition = self.querylist.get_focus()[1] else: focusposition = 0 lines = [] for (num, key) in enumerate(self.queries): value = self.queries[key] count = self.ui.dbman.count_messages('query:"%s"' % key) count_unread = self.ui.dbman.count_messages('query:"%s" and ' 'tag:unread' % key) line = QuerylineWidget(key, value, count, count_unread) if (num % 2) == 0: attr = settings.get_theming_attribute('namedqueries', 'line_even') else: attr = settings.get_theming_attribute('namedqueries', 'line_odd') focus_att = settings.get_theming_attribute('namedqueries', 'line_focus') line = urwid.AttrMap(line, attr, focus_att) lines.append(line) self.querylist = urwid.ListBox(urwid.SimpleListWalker(lines)) self.body = self.querylist self.querylist.set_focus(focusposition % len(self.queries)) self.isinitialized = True def focus_first(self): """Focus the first line in the query list.""" self.body.set_focus(0) def focus_last(self): allpos = self.querylist.body.positions(reverse=True) if allpos: lastpos = allpos[0] self.body.set_focus(lastpos) def get_selected_query(self): """returns selected query""" return self.querylist.get_focus()[0].original_widget.query def get_info(self): info = {} info['query_count'] = len(self.queries) return info alot-0.11/alot/buffers/search.py000066400000000000000000000115011466311112200165770ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import urwid from notmuch2 import NotmuchError from .buffer import Buffer from ..settings.const import settings from ..walker import IterableWalker from ..widgets.search import ThreadlineWidget class SearchBuffer(Buffer): """shows a result list of threads for a query""" modename = 'search' threads = [] _REVERSE = {'oldest_first': 'newest_first', 'newest_first': 'oldest_first'} def __init__(self, ui, initialquery='', sort_order=None): self.dbman = ui.dbman self.ui = ui self.querystring = initialquery default_order = settings.get('search_threads_sort_order') self.sort_order = sort_order or default_order self.result_count = 0 self.search_threads_rebuild_limit = \ settings.get('search_threads_rebuild_limit') self.search_threads_move_last_limit = \ settings.get('search_threads_move_last_limit') self.isinitialized = False self.threadlist = None self.rebuild() Buffer.__init__(self, ui, self.body) def __str__(self): formatstring = '[search] for "%s" (%d message%s)' return formatstring % (self.querystring, self.result_count, 's' if self.result_count > 1 else '') def get_info(self): info = {} info['querystring'] = self.querystring info['result_count'] = self.result_count info['result_count_positive'] = 's' if self.result_count > 1 else '' return info def rebuild(self, reverse=False, restore_focus=True): self.isinitialized = True self.reversed = reverse selected_thread = None if reverse: order = self._REVERSE[self.sort_order] else: order = self.sort_order if restore_focus and self.threadlist: selected_thread = self.get_selected_thread() try: self.result_count = self.dbman.count_messages(self.querystring) threads = self.dbman.get_threads(self.querystring, order) except NotmuchError: self.ui.notify('malformed query string: %s' % self.querystring, 'error') self.listbox = urwid.ListBox([]) self.body = self.listbox return self.threadlist = IterableWalker(threads, ThreadlineWidget, dbman=self.dbman, reverse=reverse) self.listbox = urwid.ListBox(self.threadlist) self.body = self.listbox if selected_thread: self.focus_thread(selected_thread) def get_selected_threadline(self): """ returns curently focussed :class:`alot.widgets.ThreadlineWidget` from the result list. """ threadlinewidget, _ = self.threadlist.get_focus() return threadlinewidget def get_selected_thread(self): """returns currently selected :class:`~alot.db.Thread`""" threadlinewidget = self.get_selected_threadline() thread = None if threadlinewidget: thread = threadlinewidget.get_thread() return thread def consume_pipe(self): while not self.threadlist.empty: self.threadlist._get_next_item() def consume_pipe_until(self, predicate, limit=0): n = limit while not limit or n > 0: if self.threadlist.empty \ or predicate(self.threadlist._get_next_item()): break n -= 1 def focus_first(self): if not self.reversed: self.body.set_focus(0) else: self.rebuild(reverse=False, restore_focus=False) self.body.set_focus(0) def focus_last(self): if self.reversed: self.body.set_focus(0) elif self.search_threads_move_last_limit == 0 \ or self.result_count < self.search_threads_move_last_limit \ or self.sort_order not in self._REVERSE: self.consume_pipe() num_lines = len(self.threadlist.get_lines()) self.body.set_focus(num_lines - 1) else: self.rebuild(reverse=True, restore_focus=False) self.body.set_focus(0) def focus_thread(self, thread): tid = thread.get_thread_id() self.consume_pipe_until(lambda w: w and w.get_thread().get_thread_id() == tid, self.search_threads_rebuild_limit) for pos, threadlinewidget in enumerate(self.threadlist.get_lines()): if threadlinewidget.get_thread().get_thread_id() == tid: self.body.set_focus(pos) break alot-0.11/alot/buffers/taglist.py000066400000000000000000000045471466311112200170150ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import urwid from .buffer import Buffer from ..settings.const import settings from ..widgets.globals import TagWidget class TagListBuffer(Buffer): """lists all tagstrings present in the notmuch database""" modename = 'taglist' def __init__(self, ui, alltags=None, filtfun=lambda x: x): self.filtfun = filtfun self.ui = ui self.tags = alltags or [] self.isinitialized = False self.rebuild() Buffer.__init__(self, ui, self.body) def rebuild(self): if self.isinitialized: focusposition = self.taglist.get_focus()[1] else: focusposition = 0 self.isinitialized = True lines = list() displayedtags = sorted((t for t in self.tags if self.filtfun(t)), key=str.lower) for (num, b) in enumerate(displayedtags): if (num % 2) == 0: attr = settings.get_theming_attribute('taglist', 'line_even') else: attr = settings.get_theming_attribute('taglist', 'line_odd') focus_att = settings.get_theming_attribute('taglist', 'line_focus') tw = TagWidget(b, attr, focus_att) rows = [('fixed', tw.width(), tw)] if tw.hidden: rows.append(urwid.Text(b + ' [hidden]')) elif tw.translated is not b: rows.append(urwid.Text('(%s)' % b)) line = urwid.Columns(rows, dividechars=1) line = urwid.AttrMap(line, attr, focus_att) lines.append(line) self.taglist = urwid.ListBox(urwid.SimpleListWalker(lines)) self.body = self.taglist self.taglist.set_focus(focusposition % len(displayedtags)) def focus_first(self): """Focus the first line in the tag list.""" self.body.set_focus(0) def focus_last(self): allpos = self.taglist.body.positions(reverse=True) if allpos: lastpos = allpos[0] self.body.set_focus(lastpos) def get_selected_tag(self): """returns selected tagstring""" cols, _ = self.taglist.get_focus() tagwidget = cols.original_widget.get_focus() return tagwidget.tag alot-0.11/alot/buffers/thread.py000066400000000000000000000315541466311112200166130ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # Copyright © 2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import asyncio import urwid import logging from urwidtrees import ArrowTree, TreeBox, NestedTree from .buffer import Buffer from ..settings.const import settings from ..widgets.thread import ThreadTree from .. import commands from ..db.errors import NonexistantObjectError class ThreadBuffer(Buffer): """displays a thread as a tree of messages.""" modename = 'thread' def __init__(self, ui, thread): """ :param ui: main UI :type ui: :class:`~alot.ui.UI` :param thread: thread to display :type thread: :class:`~alot.db.Thread` """ self.thread = thread self.message_count = thread.get_total_messages() # two semaphores for auto-removal of unread tag self._auto_unread_dont_touch_mids = set([]) self._auto_unread_writing = False self._indent_width = settings.get('thread_indent_replies') self.rebuild() Buffer.__init__(self, ui, self.body) def __str__(self): return '[thread] %s (%d message%s)' % (self.thread.get_subject(), self.message_count, 's' * (self.message_count > 1)) def translated_tags_str(self, intersection=False): tags = self.thread.get_tags(intersection=intersection) trans = [settings.get_tagstring_representation(tag)['translated'] for tag in tags] return ' '.join(trans) def get_info(self): info = {} info['subject'] = self.thread.get_subject() info['authors'] = self.thread.get_authors_string() info['tid'] = self.thread.get_thread_id() info['message_count'] = self.message_count info['thread_tags'] = self.translated_tags_str() info['intersection_tags'] = self.translated_tags_str(intersection=True) info['mimetype'] = ( self.get_selected_message().get_mime_part().get_content_type()) return info def get_selected_thread(self): """Return the displayed :class:`~alot.db.Thread`.""" return self.thread def rebuild(self): try: self.thread.refresh() except NonexistantObjectError: self.body = urwid.SolidFill() self.message_count = 0 return self._tree = ThreadTree(self.thread) # define A to be the tree to be wrapped by a NestedTree and displayed. # We wrap the thread tree into an ArrowTree for decoration if # indentation was requested and otherwise use it as is. if self._indent_width == 0: A = self._tree else: # we want decoration. bars_att = settings.get_theming_attribute('thread', 'arrow_bars') # only add arrow heads if there is space (indent > 1). heads_char = None heads_att = None if self._indent_width > 1: heads_char = '➤' heads_att = settings.get_theming_attribute('thread', 'arrow_heads') A = ArrowTree( self._tree, indent=self._indent_width, childbar_offset=0, arrow_tip_att=heads_att, arrow_tip_char=heads_char, arrow_att=bars_att) self._nested_tree = NestedTree(A, interpret_covered=True) self.body = TreeBox(self._nested_tree) self.message_count = self.thread.get_total_messages() def render(self, size, focus=False): if self.message_count == 0: return self.body.render(size, focus) if settings.get('auto_remove_unread'): logging.debug('Tbuffer: auto remove unread tag from msg?') msg = self.get_selected_message() mid = msg.get_message_id() focus_pos = self.body.get_focus()[1] summary_pos = (self.body.get_focus()[1][0], (0,)) cursor_on_non_summary = (focus_pos != summary_pos) if cursor_on_non_summary: if mid not in self._auto_unread_dont_touch_mids: if 'unread' in msg.get_tags(): logging.debug('Tbuffer: removing unread') def clear(): self._auto_unread_writing = False self._auto_unread_dont_touch_mids.add(mid) self._auto_unread_writing = True msg.remove_tags(['unread'], afterwards=clear) fcmd = commands.globals.FlushCommand(silent=True) asyncio.get_event_loop().create_task( self.ui.apply_command(fcmd)) else: logging.debug('Tbuffer: No, msg not unread') else: logging.debug('Tbuffer: No, mid locked for autorm-unread') else: if not self._auto_unread_writing and \ mid in self._auto_unread_dont_touch_mids: self._auto_unread_dont_touch_mids.remove(mid) logging.debug('Tbuffer: No, cursor on summary') return self.body.render(size, focus) def get_selected_mid(self): """Return Message ID of focussed message.""" return self.body.get_focus()[1][0] def get_selected_message_position(self): """Return position of focussed message in the thread tree.""" return self._sanitize_position((self.get_selected_mid(),)) def get_selected_messagetree(self): """Return currently focussed :class:`MessageTree`.""" return self._nested_tree[self.body.get_focus()[1][:1]] def get_selected_message(self): """Return focussed :class:`~alot.db.message.Message`.""" return self.get_selected_messagetree()._message def get_messagetree_positions(self): """ Return a Generator to walk through all positions of :class:`MessageTree` in the :class:`ThreadTree` of this buffer. """ return [(pos,) for pos in self._tree.positions()] def messagetrees(self): """ returns a Generator of all :class:`MessageTree` in the :class:`ThreadTree` of this buffer. """ for pos in self._tree.positions(): yield self._tree[pos] def refresh(self): """Refresh and flush caches of Thread tree.""" self.body.refresh() # needed for ui.get_deep_focus.. def get_focus(self): "Get the focus from the underlying body widget." return self.body.get_focus() def set_focus(self, pos): "Set the focus in the underlying body widget." logging.debug('setting focus to %s ', pos) self.body.set_focus(pos, valign='top') def focus_first(self): """set focus to first message of thread""" self.set_focus(self._nested_tree.root) def focus_last(self): self.set_focus(next(self._nested_tree.positions(reverse=True))) def _sanitize_position(self, pos): return self._nested_tree._sanitize_position(pos, self._nested_tree._tree) def focus_selected_message(self): """focus the summary line of currently focussed message""" # move focus to summary (root of current MessageTree) self.set_focus(self.get_selected_message_position()) def focus_parent(self): """move focus to parent of currently focussed message""" mid = self.get_selected_mid() newpos = self._tree.parent_position(mid) if newpos is not None: newpos = self._sanitize_position((newpos,)) self.set_focus(newpos) def focus_first_reply(self): """move focus to first reply to currently focussed message""" mid = self.get_selected_mid() newpos = self._tree.first_child_position(mid) if newpos is not None: newpos = self._sanitize_position((newpos,)) self.set_focus(newpos) def focus_last_reply(self): """move focus to last reply to currently focussed message""" mid = self.get_selected_mid() newpos = self._tree.last_child_position(mid) if newpos is not None: newpos = self._sanitize_position((newpos,)) self.set_focus(newpos) def focus_next_sibling(self): """focus next sibling of currently focussed message in thread tree""" mid = self.get_selected_mid() newpos = self._tree.next_sibling_position(mid) if newpos is not None: newpos = self._sanitize_position((newpos,)) self.set_focus(newpos) def focus_prev_sibling(self): """ focus previous sibling of currently focussed message in thread tree """ mid = self.get_selected_mid() localroot = self._sanitize_position((mid,)) if localroot == self.get_focus()[1]: newpos = self._tree.prev_sibling_position(mid) if newpos is not None: newpos = self._sanitize_position((newpos,)) else: newpos = localroot if newpos is not None: self.set_focus(newpos) def focus_next(self): """focus next message in depth first order""" mid = self.get_selected_mid() newpos = self._tree.next_position(mid) if newpos is not None: newpos = self._sanitize_position((newpos,)) self.set_focus(newpos) def focus_prev(self): """focus previous message in depth first order""" mid = self.get_selected_mid() localroot = self._sanitize_position((mid,)) if localroot == self.get_focus()[1]: newpos = self._tree.prev_position(mid) if newpos is not None: newpos = self._sanitize_position((newpos,)) else: newpos = localroot if newpos is not None: self.set_focus(newpos) def focus_property(self, prop, direction): """does a walk in the given direction and focuses the first message tree that matches the given property""" newpos = self.get_selected_mid() newpos = direction(newpos) while newpos is not None: MT = self._tree[newpos] if prop(MT): newpos = self._sanitize_position((newpos,)) self.set_focus(newpos) break newpos = direction(newpos) def focus_next_matching(self, querystring): """focus next matching message in depth first order""" self.focus_property(lambda x: x._message.matches(querystring), self._tree.next_position) def focus_prev_matching(self, querystring): """focus previous matching message in depth first order""" self.focus_property(lambda x: x._message.matches(querystring), self._tree.prev_position) def focus_next_unfolded(self): """focus next unfolded message in depth first order""" self.focus_property(lambda x: not x.is_collapsed(x.root), self._tree.next_position) def focus_prev_unfolded(self): """focus previous unfolded message in depth first order""" self.focus_property(lambda x: not x.is_collapsed(x.root), self._tree.prev_position) def expand(self, msgpos): """expand message at given position""" MT = self._tree[msgpos] MT.expand(MT.root) def messagetree_at_position(self, pos): """get :class:`MessageTree` for given position""" return self._tree[pos[0]] def expand_all(self): """expand all messages in thread""" for MT in self.messagetrees(): MT.expand(MT.root) def collapse(self, msgpos): """collapse message at given position""" MT = self._tree[msgpos] MT.collapse(MT.root) self.focus_selected_message() def collapse_all(self): """collapse all messages in thread""" for MT in self.messagetrees(): MT.collapse(MT.root) self.focus_selected_message() def unfold_matching(self, querystring, focus_first=True): """ expand all messages that match a given querystring. :param querystring: query to match :type querystring: str :param focus_first: set the focus to the first matching message :type focus_first: bool """ first = None for MT in self.messagetrees(): msg = MT._message if msg.matches(querystring): MT.expand(MT.root) if first is None: first = (self._tree.position_of_messagetree(MT), MT.root) self.set_focus(first) else: MT.collapse(MT.root) self.body.refresh() alot-0.11/alot/commands/000077500000000000000000000000001466311112200151275ustar00rootroot00000000000000alot-0.11/alot/commands/__init__.py000066400000000000000000000141111466311112200172360ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import argparse import glob import logging import os import re from ..settings.const import settings from ..helper import split_commandstring, string_decode class Command: """base class for commands""" repeatable = False def __init__(self): self.prehook = None self.posthook = None self.undoable = False self.help = self.__doc__ def apply(self, ui): """code that gets executed when this command is applied""" pass class CommandCanceled(Exception): """ Exception triggered when an interactive command has been cancelled """ pass class SequenceCanceled(Exception): """ Exception triggered when a command sequence has been cancelled by the confirmsequence command """ pass COMMANDS = { 'search': {}, 'envelope': {}, 'bufferlist': {}, 'taglist': {}, 'namedqueries': {}, 'thread': {}, 'global': {}, } def lookup_command(cmdname, mode): """ returns commandclass, argparser and forced parameters used to construct a command for `cmdname` when called in `mode`. :param cmdname: name of the command to look up :type cmdname: str :param mode: mode identifier :type mode: str :rtype: (:class:`Command`, :class:`~argparse.ArgumentParser`, dict(str->dict)) """ if cmdname in COMMANDS[mode]: return COMMANDS[mode][cmdname] elif cmdname in COMMANDS['global']: return COMMANDS['global'][cmdname] else: return None, None, None def lookup_parser(cmdname, mode): """ returns the :class:`CommandArgumentParser` used to construct a command for `cmdname` when called in `mode`. """ return lookup_command(cmdname, mode)[1] class CommandParseError(Exception): """could not parse commandline string""" pass class CommandArgumentParser(argparse.ArgumentParser): """ :class:`~argparse.ArgumentParser` that raises :class:`CommandParseError` instead of printing to `sys.stderr`""" def exit(self, message): raise CommandParseError(message) def error(self, message): raise CommandParseError(message) class registerCommand: """ Decorator used to register a :class:`Command` as handler for command `name` in `mode` so that it can be looked up later using :func:`lookup_command`. Consider this example that shows how a :class:`Command` class definition is decorated to register it as handler for 'save' in mode 'thread' and add boolean and string arguments:: .. code-block:: @registerCommand('thread', 'save', arguments=[ (['--all'], {'action': 'store_true', 'help':'save all'}), (['path'], {'nargs':'?', 'help':'path to save to'})], help='save attachment(s)') class SaveAttachmentCommand(Command): pass """ def __init__(self, mode, name, help=None, usage=None, forced=None, arguments=None): """ :param mode: mode identifier :type mode: str :param name: command name to register as :type name: str :param help: help string summarizing what this command does :type help: str :param usage: overides the auto generated usage string :type usage: str :param forced: keyword parameter used for commands constructor :type forced: dict (str->str) :param arguments: list of arguments given as pairs (args, kwargs) accepted by :meth:`argparse.ArgumentParser.add_argument`. :type arguments: list of (list of str, dict (str->str) """ self.mode = mode self.name = name self.help = help self.usage = usage self.forced = forced or {} self.arguments = arguments or [] def __call__(self, klass): helpstring = self.help or klass.__doc__ argparser = CommandArgumentParser(description=helpstring, usage=self.usage, prog=self.name, add_help=False) for args, kwargs in self.arguments: argparser.add_argument(*args, **kwargs) COMMANDS[self.mode][self.name] = (klass, argparser, self.forced) return klass def commandfactory(cmdline, mode='global'): """ parses `cmdline` and constructs a :class:`Command`. :param cmdline: command line to interpret :type cmdline: str :param mode: mode identifier :type mode: str """ # split commandname and parameters if not cmdline: return None logging.debug('mode:%s got commandline "%s"', mode, cmdline) # allow to shellescape without a space after '!' if cmdline.startswith('!'): cmdline = 'shellescape \'%s\'' % cmdline[1:] cmdline = re.sub(r'"(.*)"', r'"\\"\1\\""', cmdline) try: args = split_commandstring(cmdline) except ValueError as e: raise CommandParseError(str(e)) args = [string_decode(x, 'utf-8') for x in args] logging.debug('ARGS: %s', args) cmdname = args[0] args = args[1:] # unfold aliases # TODO: read from settingsmanager # get class, argparser and forced parameter (cmdclass, parser, forcedparms) = lookup_command(cmdname, mode) if cmdclass is None: msg = 'unknown command: %s' % cmdname logging.debug(msg) raise CommandParseError(msg) parms = vars(parser.parse_args(args)) parms.update(forcedparms) logging.debug('cmd parms %s', parms) # create Command cmd = cmdclass(**parms) # set pre and post command hooks get_hook = settings.get_hook cmd.prehook = get_hook('pre_%s_%s' % (mode, cmdname)) or \ get_hook('pre_global_%s' % cmdname) cmd.posthook = get_hook('post_%s_%s' % (mode, cmdname)) or \ get_hook('post_global_%s' % cmdname) return cmd pyfiles = glob.glob1(os.path.dirname(__file__), '*.py') __all__ = list(filename[:-3] for filename in pyfiles) alot-0.11/alot/commands/bufferlist.py000066400000000000000000000016131466311112200176470ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # Copyright © 2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from ..commands import Command, registerCommand from . import globals MODE = 'bufferlist' @registerCommand(MODE, 'open') class BufferFocusCommand(Command): """focus selected buffer""" def apply(self, ui): selected = ui.current_buffer.get_selected_buffer() ui.buffer_focus(selected) @registerCommand(MODE, 'close') class BufferCloseCommand(Command): """close focussed buffer""" async def apply(self, ui): bufferlist = ui.current_buffer selected = bufferlist.get_selected_buffer() await ui.apply_command(globals.BufferCloseCommand(buffer=selected)) if bufferlist is not selected: bufferlist.rebuild() ui.update() alot-0.11/alot/commands/common.py000066400000000000000000000017471466311112200170020ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # Copyright © 2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from . import Command from .globals import PromptCommand class RetagPromptCommand(Command): """prompt to retag selected thread's or message's tags""" async def apply(self, ui): get_selected_item = getattr(ui.current_buffer, { 'search': 'get_selected_thread', 'thread': 'get_selected_message'}[ui.mode]) item = get_selected_item() if not item: return tags = [] for tag in item.get_tags(): if ' ' in tag: tags.append('"%s"' % tag) # skip empty tags elif tag: tags.append(tag) initial_tagstring = ','.join(sorted(tags)) + ',' r = await ui.apply_command(PromptCommand('retag ' + initial_tagstring)) return r alot-0.11/alot/commands/envelope.py000066400000000000000000000735461466311112200173350ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # Copyright © 2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import argparse import datetime import email import email.policy import fnmatch import glob import logging import os import re import tempfile import textwrap import traceback import sys from . import Command, registerCommand from . import globals from . import utils from .. import buffers from .. import commands from .. import crypto from ..account import SendingMailFailed, StoreMailError from ..db.errors import DatabaseError from ..errors import GPGProblem, ConversionError from ..helper import string_decode from ..helper import call_cmd from ..helper import split_commandstring from ..settings.const import settings from ..settings.errors import NoMatchingAccount from ..utils import argparse as cargparse from ..utils.collections import OrderedSet MODE = 'envelope' @registerCommand( MODE, 'attach', arguments=[(['path'], {'help': 'file(s) to attach (accepts wildcards)'})]) class AttachCommand(Command): """attach files to the mail""" repeatable = True def __init__(self, path, **kwargs): """ :param path: files to attach (globable string) :type path: str """ Command.__init__(self, **kwargs) self.path = path def apply(self, ui): envelope = ui.current_buffer.envelope files = [g for g in glob.glob(os.path.expanduser(self.path)) if os.path.isfile(g)] if not files: ui.notify('no matches, abort') return logging.info("attaching: %s", files) for path in files: envelope.attach(path) ui.current_buffer.rebuild() @registerCommand(MODE, 'detach', arguments=[ (['files'], { 'nargs': '?', 'help': 'name of the attachment to remove (accepts wildcards)' }), ]) class DetachCommand(Command): """remove attachments from current envelope""" repeatable = True def __init__(self, files=None, **kwargs): """ :param files: attached file glob to remove :type files: str """ Command.__init__(self, **kwargs) self.files = files or '*' def apply(self, ui): envelope = ui.current_buffer.envelope envelope.attachments = [ attachment for attachment in envelope.attachments if not fnmatch.fnmatch(attachment.get_filename(), self.files) ] ui.current_buffer.rebuild() @registerCommand(MODE, 'refine', arguments=[ (['key'], {'help': 'header to refine'})]) class RefineCommand(Command): """prompt to change the value of a header""" def __init__(self, key='', **kwargs): """ :param key: key of the header to change :type key: str """ Command.__init__(self, **kwargs) self.key = key async def apply(self, ui): value = ui.current_buffer.envelope.get(self.key, '') cmdstring = 'set %s %s' % (self.key, value) await ui.apply_command(globals.PromptCommand(cmdstring)) @registerCommand(MODE, 'save') class SaveCommand(Command): """save draft""" async def apply(self, ui): envelope = ui.current_buffer.envelope # determine account to use if envelope.account is None: try: envelope.account = settings.account_matching_address( envelope['From'], return_default=True) except NoMatchingAccount: ui.notify('no accounts set.', priority='error') return account = envelope.account if account.draft_box is None: msg = 'abort: Account for {} has no draft_box' ui.notify(msg.format(account.address), priority='error') return mail = envelope.construct_mail() # store mail locally path = account.store_draft_mail( mail.as_string(policy=email.policy.SMTP, maxheaderlen=sys.maxsize)) msg = 'draft saved successfully' # add mail to index if maildir path available if path is not None: ui.notify(msg + ' to %s' % path) logging.debug('adding new mail to index') try: ui.dbman.add_message(path, account.draft_tags + envelope.tags) await ui.apply_command(globals.FlushCommand()) await ui.apply_command(commands.globals.BufferCloseCommand()) except DatabaseError as e: logging.error(str(e)) ui.notify('could not index message:\n%s' % str(e), priority='error', block=True) else: await ui.apply_command(commands.globals.BufferCloseCommand()) @registerCommand(MODE, 'send') class SendCommand(Command): """send mail""" def __init__(self, mail=None, envelope=None, **kwargs): """ :param mail: email to send :type email: email.message.Message :param envelope: envelope to use to construct the outgoing mail. This will be ignored in case the mail parameter is set. :type envelope: alot.db.envelope.envelope """ Command.__init__(self, **kwargs) self.mail = mail self.envelope = envelope self.envelope_buffer = None def _get_keys_addresses(self): addresses = set() for key in self.envelope.encrypt_keys.values(): for uid in key.uids: addresses.add(uid.email) return addresses def _get_recipients_addresses(self): tos = self.envelope.headers.get('To', []) ccs = self.envelope.headers.get('Cc', []) return {a for (_, a) in email.utils.getaddresses(tos + ccs)} def _is_encrypted_to_all_recipients(self): recipients_addresses = self._get_recipients_addresses() keys_addresses = self._get_keys_addresses() return recipients_addresses.issubset(keys_addresses) async def apply(self, ui): if self.mail is None: if self.envelope is None: # needed to close later self.envelope_buffer = ui.current_buffer self.envelope = self.envelope_buffer.envelope # This is to warn the user before re-sending # an already sent message in case the envelope buffer # was not closed because it was the last remaining buffer. if self.envelope.sent_time: mod = self.envelope.modified_since_sent when = self.envelope.sent_time warning = 'A modified version of ' * mod warning += 'this message has been sent at %s.' % when warning += ' Do you want to resend?' if (await ui.choice(warning, cancel='no', msg_position='left')) == 'no': return # don't do anything if another SendCommand is in the middle of # sending the message and we were triggered accidentally if self.envelope.sending: logging.debug('sending this message already!') return # Before attempting to construct mail, ensure that we're not trying # to encrypt a message with a BCC, since any BCC recipients will # receive a message that they cannot read! if self.envelope.headers.get('Bcc') and self.envelope.encrypt: warning = textwrap.dedent("""\ Any BCC recipients will not be able to decrypt this message. Do you want to send anyway?""").replace('\n', ' ') if (await ui.choice(warning, cancel='no', msg_position='left')) == 'no': return # Check if an encrypted message is indeed encrypted to all its # recipients. if (self.envelope.encrypt and not self._is_encrypted_to_all_recipients()): warning = textwrap.dedent("""\ Message is not encrypted to all recipients. This means that not everyone will be able to decode and read this message. Do you want to send anyway?""").replace('\n', ' ') if (await ui.choice(warning, cancel='no', msg_position='left')) == 'no': return clearme = ui.notify('constructing mail (GPG, attachments)…', timeout=-1) try: self.mail = self.envelope.construct_mail() self.mail = self.mail.as_string(policy=email.policy.SMTP, maxheaderlen=sys.maxsize) except GPGProblem as e: ui.clear_notify([clearme]) ui.notify(str(e), priority='error') return ui.clear_notify([clearme]) # determine account to use for sending msg = self.mail if not isinstance(msg, email.message.Message): msg = email.message_from_string( self.mail, policy=email.policy.SMTP) address = msg.get('Resent-From', False) or msg.get('From', '') logging.debug("FROM: \"%s\"" % address) try: account = settings.account_matching_address(address, return_default=True) except NoMatchingAccount: ui.notify('no accounts set', priority='error') return logging.debug("ACCOUNT: \"%s\"" % account.address) # send out clearme = ui.notify('sending..', timeout=-1) if self.envelope is not None: self.envelope.sending = True try: await account.send_mail(self.mail) except SendingMailFailed as e: if self.envelope is not None: self.envelope.account = account self.envelope.sending = False ui.clear_notify([clearme]) logging.error(traceback.format_exc()) errmsg = 'failed to send: {}'.format(e) ui.notify(errmsg, priority='error', block=True) except StoreMailError as e: ui.clear_notify([clearme]) logging.error(traceback.format_exc()) errmsg = 'could not store mail: {}'.format(e) ui.notify(errmsg, priority='error', block=True) else: initial_tags = [] if self.envelope is not None: self.envelope.sending = False self.envelope.sent_time = datetime.datetime.now() initial_tags = self.envelope.tags logging.debug('mail sent successfully') ui.clear_notify([clearme]) if self.envelope_buffer is not None: cmd = commands.globals.BufferCloseCommand(self.envelope_buffer) await ui.apply_command(cmd) ui.notify('mail sent successfully') if self.envelope is not None: if self.envelope.replied: self.envelope.replied.add_tags(account.replied_tags) if self.envelope.passed: self.envelope.passed.add_tags(account.passed_tags) # store mail locally # This can raise StoreMailError path = account.store_sent_mail(self.mail) # add mail to index if maildir path available if path is not None: logging.debug('adding new mail to index') ui.dbman.add_message(path, account.sent_tags + initial_tags) await ui.apply_command(globals.FlushCommand()) @registerCommand(MODE, 'edit', arguments=[ (['--spawn'], {'action': cargparse.BooleanAction, 'default': None, 'help': 'spawn editor in new terminal'}), (['--refocus'], {'action': cargparse.BooleanAction, 'default': True, 'help': 'refocus envelope after editing'}), (['--part'], {'help': 'which alternative to edit ("html" or "plaintext")', 'choices': ['html', 'plaintext']}), ]) class EditCommand(Command): """edit mail""" def __init__(self, envelope=None, spawn=None, refocus=True, part=None, **kwargs): """ :param envelope: email to edit :type envelope: :class:`~alot.db.envelope.Envelope` :param spawn: force spawning of editor in a new terminal :type spawn: bool :param refocus: m :param part: which alternative to edit :type part: str """ self.envelope = envelope self.openNew = (envelope is not None) self.force_spawn = spawn self.refocus = refocus self.edit_only_body = False self.edit_part = settings.get('envelope_edit_default_alternative') if part in ['html', 'plaintext']: self.edit_part = part logging.debug('edit_part: %s ' % self.edit_part) Command.__init__(self, **kwargs) async def apply(self, ui): ebuffer = ui.current_buffer if not self.envelope: self.envelope = ebuffer.envelope # determine editable headers edit_headers = OrderedSet(settings.get('edit_headers_whitelist')) if '*' in edit_headers: edit_headers = OrderedSet(self.envelope.headers) blacklist = set(settings.get('edit_headers_blacklist')) if '*' in blacklist: blacklist = set(self.envelope.headers) edit_headers = edit_headers - blacklist logging.info('editable headers: %s', edit_headers) def openEnvelopeFromTmpfile(): # This parses the input from the tempfile. # we do this ourselves here because we want to be able to # just type utf-8 encoded stuff into the tempfile and let alot # worry about encodings. # get input # tempfile will be removed on buffer cleanup enc = settings.get('editor_writes_encoding') with open(self.envelope.tmpfile.name) as f: template = string_decode(f.read(), enc) # call post-edit translate hook translate = settings.get_hook('post_edit_translate') if translate: template = translate(template, ui=ui, dbm=ui.dbman) logging.debug('target bodypart: %s' % self.edit_part) self.envelope.parse_template(template, only_body=self.edit_only_body, target_body=self.edit_part) if self.openNew: ui.buffer_open(buffers.EnvelopeBuffer(ui, self.envelope)) else: ebuffer.envelope = self.envelope ebuffer.rebuild() # decode header headertext = '' for key in edit_headers: vlist = self.envelope.get_all(key) if not vlist: # ensure editable headers are present in template vlist = [''] else: # remove to be edited lines from envelope del self.envelope[key] for value in vlist: # newlines (with surrounding spaces) by spaces in values value = value.strip() value = re.sub('[ \t\r\f\v]*\n[ \t\r\f\v]*', ' ', value) headertext += '%s: %s\n' % (key, value) # determine which part to edit logging.debug('edit_part: %s ' % self.edit_part) if self.edit_part is None: # I can't access ebuffer in my constructor, hence the check here if isinstance(ebuffer, buffers.EnvelopeBuffer): if ebuffer.displaypart in ['html', 'src']: self.edit_part = 'html' logging.debug('displaypart: %s' % ebuffer.displaypart) if self.edit_part == 'html': bodytext = self.envelope.body_html logging.debug('editing HTML source') else: self.edit_part = 'plaintext' bodytext = self.envelope.body_txt logging.debug('editing plaintext') # determine editable content if headertext: content = '%s\n%s' % (headertext, bodytext) self.edit_only_body = False else: content = bodytext self.edit_only_body = True # call pre-edit translate hook translate = settings.get_hook('pre_edit_translate') if translate: content = translate(content, ui=ui, dbm=ui.dbman) # write stuff to tempfile old_tmpfile = None if self.envelope.tmpfile: old_tmpfile = self.envelope.tmpfile with tempfile.NamedTemporaryFile( delete=False, prefix='alot.', suffix='.eml') as tmpfile: tmpfile.write(content.encode('utf-8')) tmpfile.flush() self.envelope.tmpfile = tmpfile if old_tmpfile: os.unlink(old_tmpfile.name) cmd = globals.EditCommand(self.envelope.tmpfile.name, on_success=openEnvelopeFromTmpfile, spawn=self.force_spawn, thread=self.force_spawn, refocus=self.refocus) await ui.apply_command(cmd) @registerCommand(MODE, 'set', arguments=[ (['--append'], {'action': 'store_true', 'help': 'keep previous values'}), (['key'], {'help': 'header to refine'}), (['value'], {'nargs': '+', 'help': 'value'})]) class SetCommand(Command): """set header value""" def __init__(self, key, value, append=False, **kwargs): """ :param key: key of the header to change :type key: str :param value: new value :type value: str """ self.key = key self.value = ' '.join(value) self.reset = not append Command.__init__(self, **kwargs) async def apply(self, ui): envelope = ui.current_buffer.envelope if self.reset: if self.key in envelope: del envelope[self.key] envelope.add(self.key, self.value) # FIXME: handle BCC as well # Currently we don't handle bcc because it creates a side channel leak, # as the key of the person BCC'd will be available to other recievers, # defeating the purpose of BCCing them if self.key.lower() in ['to', 'from', 'cc'] and envelope.encrypt: await utils.update_keys(ui, envelope) ui.current_buffer.rebuild() @registerCommand(MODE, 'unset', arguments=[ (['key'], {'help': 'header to refine'})]) class UnsetCommand(Command): """remove header field""" def __init__(self, key, **kwargs): """ :param key: key of the header to remove :type key: str """ self.key = key Command.__init__(self, **kwargs) async def apply(self, ui): del ui.current_buffer.envelope[self.key] # FIXME: handle BCC as well # Currently we don't handle bcc because it creates a side channel leak, # as the key of the person BCC'd will be available to other recievers, # defeating the purpose of BCCing them if self.key.lower() in ['to', 'from', 'cc']: await utils.update_keys(ui, ui.current_buffer.envelope) ui.current_buffer.rebuild() @registerCommand(MODE, 'toggleheaders') class ToggleHeaderCommand(Command): """toggle display of all headers""" repeatable = True def apply(self, ui): ui.current_buffer.toggle_all_headers() @registerCommand( MODE, 'sign', forced={'action': 'sign'}, arguments=[ (['keyid'], {'nargs': argparse.REMAINDER, 'help': 'which key id to use'})], help='mark mail to be signed before sending') @registerCommand(MODE, 'unsign', forced={'action': 'unsign'}, help='mark mail not to be signed before sending') @registerCommand( MODE, 'togglesign', forced={'action': 'toggle'}, arguments=[ (['keyid'], {'nargs': argparse.REMAINDER, 'help': 'which key id to use'})], help='toggle sign status') class SignCommand(Command): """toggle signing this email""" repeatable = True def __init__(self, action=None, keyid=None, **kwargs): """ :param action: whether to sign/unsign/toggle :type action: str :param keyid: which key id to use :type keyid: str """ self.action = action self.keyid = keyid Command.__init__(self, **kwargs) def apply(self, ui): sign = None envelope = ui.current_buffer.envelope # sign status if self.action == 'sign': sign = True elif self.action == 'unsign': sign = False elif self.action == 'toggle': sign = not envelope.sign envelope.sign = sign if sign: if self.keyid: # try to find key if hint given as parameter keyid = str(' '.join(self.keyid)) try: envelope.sign_key = crypto.get_key(keyid, validate=True, sign=True) except GPGProblem as e: envelope.sign = False ui.notify(str(e), priority='error') return else: if envelope.account is None: try: envelope.account = settings.account_matching_address( envelope['From']) except NoMatchingAccount: envelope.sign = False ui.notify('Unable to find a matching account', priority='error') return acc = envelope.account if not acc.gpg_key: envelope.sign = False msg = 'Account for {} has no gpg key' ui.notify(msg.format(acc.address), priority='error') return envelope.sign_key = acc.gpg_key else: envelope.sign_key = None # reload buffer ui.current_buffer.rebuild() @registerCommand( MODE, 'encrypt', forced={'action': 'encrypt'}, arguments=[ (['--trusted'], {'action': 'store_true', 'help': 'only add trusted keys'}), (['keyids'], {'nargs': argparse.REMAINDER, 'help': 'keyid of the key to encrypt with'})], help='request encryption of message before sendout') @registerCommand( MODE, 'unencrypt', forced={'action': 'unencrypt'}, help='remove request to encrypt message before sending') @registerCommand( MODE, 'toggleencrypt', forced={'action': 'toggleencrypt'}, arguments=[ (['--trusted'], {'action': 'store_true', 'help': 'only add trusted keys'}), (['keyids'], {'nargs': argparse.REMAINDER, 'help': 'keyid of the key to encrypt with'})], help='toggle if message should be encrypted before sendout') @registerCommand( MODE, 'rmencrypt', forced={'action': 'rmencrypt'}, arguments=[ (['keyids'], {'nargs': argparse.REMAINDER, 'help': 'keyid of the key to encrypt with'})], help='do not encrypt to given recipient key') class EncryptCommand(Command): def __init__(self, action=None, keyids=None, trusted=False, **kwargs): """ :param action: wether to encrypt/unencrypt/toggleencrypt :type action: str :param keyid: the id of the key to encrypt :type keyid: str :param trusted: wether to filter keys and only use trusted ones :type trusted: bool """ self.encrypt_keys = keyids self.action = action self.trusted = trusted Command.__init__(self, **kwargs) async def apply(self, ui): envelope = ui.current_buffer.envelope if self.action == 'rmencrypt': try: for keyid in self.encrypt_keys: tmp_key = crypto.get_key(keyid) del envelope.encrypt_keys[tmp_key.fpr] except GPGProblem as e: ui.notify(str(e), priority='error') if not envelope.encrypt_keys: envelope.encrypt = False ui.current_buffer.rebuild() return elif self.action == 'encrypt': encrypt = True elif self.action == 'unencrypt': encrypt = False elif self.action == 'toggleencrypt': encrypt = not envelope.encrypt if encrypt: if self.encrypt_keys: for keyid in self.encrypt_keys: tmp_key = crypto.get_key(keyid) envelope.encrypt_keys[tmp_key.fpr] = tmp_key else: await utils.update_keys(ui, envelope, signed_only=self.trusted) envelope.encrypt = encrypt if not envelope.encrypt: # This is an extra conditional as it can even happen if encrypt is # True. envelope.encrypt_keys = {} # reload buffer ui.current_buffer.rebuild() @registerCommand( MODE, 'tag', forced={'action': 'add'}, arguments=[(['tags'], {'help': 'comma separated list of tags'})], help='add tags to message', ) @registerCommand( MODE, 'retag', forced={'action': 'set'}, arguments=[(['tags'], {'help': 'comma separated list of tags'})], help='set message tags', ) @registerCommand( MODE, 'untag', forced={'action': 'remove'}, arguments=[(['tags'], {'help': 'comma separated list of tags'})], help='remove tags from message', ) @registerCommand( MODE, 'toggletags', forced={'action': 'toggle'}, arguments=[(['tags'], {'help': 'comma separated list of tags'})], help='flip presence of tags on message', ) class TagCommand(Command): """manipulate message tags""" repeatable = True def __init__(self, tags='', action='add', **kwargs): """ :param tags: comma separated list of tagstrings to set :type tags: str :param action: adds tags if 'add', removes them if 'remove', adds tags and removes all other if 'set' or toggle individually if 'toggle' :type action: str """ assert isinstance(tags, str), 'tags should be a unicode string' self.tagsstring = tags self.action = action Command.__init__(self, **kwargs) def apply(self, ui): ebuffer = ui.current_buffer envelope = ebuffer.envelope tags = {t for t in self.tagsstring.split(',') if t} old = set(envelope.tags) if self.action == 'add': new = old.union(tags) elif self.action == 'remove': new = old.difference(tags) elif self.action == 'set': new = tags elif self.action == 'toggle': new = old.symmetric_difference(tags) envelope.tags = sorted(new) # reload buffer ui.current_buffer.rebuild() @registerCommand( MODE, 'html2txt', forced={'action': 'html2txt'}, arguments=[(['cmd'], {'nargs': argparse.REMAINDER, 'help': 'converter command to use'})], help='convert html to plaintext alternative', ) @registerCommand( MODE, 'txt2html', forced={'action': 'txt2html'}, arguments=[(['cmd'], {'nargs': argparse.REMAINDER, 'help': 'converter command to use'})], help='convert plaintext to html alternative', ) class BodyConvertCommand(Command): def __init__(self, action=None, cmd=None): self.action = action self.cmd = cmd # this comes as a space separated list Command.__init__(self) def convert(self, cmdlist, inputstring): logging.debug("converting using %s" % cmdlist) resultstring, errmsg, retval = call_cmd(cmdlist, stdin=inputstring) if retval != 0: msg = 'converter "%s" returned with ' % cmdlist msg += 'return code %d' % retval if errmsg: msg += ':\n%s' % errmsg raise ConversionError(msg) logging.debug("resultstring is \n" + resultstring) return resultstring def apply(self, ui): ebuffer = ui.current_buffer envelope = ebuffer.envelope if self.action == "txt2html": fallbackcmd = settings.get('envelope_txt2html') cmd = self.cmd or split_commandstring(fallbackcmd) if cmd: envelope.body_html = self.convert(cmd, envelope.body_txt) elif self.action == "html2txt": fallbackcmd = settings.get('envelope_html2txt') cmd = self.cmd or split_commandstring(fallbackcmd) if cmd: envelope.body_txt = self.convert(cmd, envelope.body_html) ui.current_buffer.rebuild() @registerCommand( MODE, 'display', help='change which body alternative to display', arguments=[(['part'], {'help': 'part to show'})]) class ChangeDisplaymodeCommand(Command): """change wich body alternative is shown""" def __init__(self, part=None, **kwargs): """ :param part: which part to show :type indent: 'plaintext', 'src', or 'html' """ self.part = part Command.__init__(self, **kwargs) async def apply(self, ui): ebuffer = ui.current_buffer envelope = ebuffer.envelope # make sure that envelope has html part if requested here if self.part in ['html', 'src'] and not envelope.body_html: await ui.apply_command(BodyConvertCommand(action='txt2html')) ui.current_buffer.set_displaypart(self.part) ui.update() @registerCommand( MODE, 'removehtml', help='remove HTML alternative from the envelope', ) class RemoveHtmlCommand(Command): def apply(self, ui): ebuffer = ui.current_buffer envelope = ebuffer.envelope envelope.body_html = None ebuffer.displaypart = 'plaintext' ebuffer.rebuild() ui.update() alot-0.11/alot/commands/globals.py000066400000000000000000001261071466311112200171330ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # Copyright © 2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import argparse import code import email import email.utils import glob import logging import os import subprocess from io import BytesIO import asyncio import shlex import urwid from . import Command, registerCommand from . import CommandCanceled, SequenceCanceled from .utils import update_keys from .. import commands from .. import buffers from .. import helper from ..helper import split_commandstring from ..helper import mailto_to_envelope from ..completion.commandline import CommandLineCompleter from ..completion.contacts import ContactsCompleter from ..completion.accounts import AccountCompleter from ..completion.tags import TagsCompleter from ..widgets.utils import DialogBox from ..db.errors import DatabaseLockedError from ..db.envelope import Envelope from ..settings.const import settings from ..settings.errors import ConfigError, NoMatchingAccount from ..utils import argparse as cargparse MODE = 'global' @registerCommand(MODE, 'exit', help="shut down cleanly") class ExitCommand(Command): """Shut down cleanly.""" def __init__(self, _prompt=True, **kwargs): """ :param _prompt: For internal use only, used to control prompting to close without sending, and is used by the BufferCloseCommand if settings change after yielding to the UI. :type _prompt: bool """ super(ExitCommand, self).__init__(**kwargs) self.prompt_to_send = _prompt async def apply(self, ui): if settings.get('bug_on_exit'): msg = 'really quit?' if (await ui.choice(msg, select='yes', cancel='no', msg_position='left')) == 'no': return # check if there are any unsent messages if self.prompt_to_send: for buffer in ui.buffers: if (isinstance(buffer, buffers.EnvelopeBuffer) and not buffer.envelope.sent_time): msg = 'quit without sending message?' if (await ui.choice(msg, cancel='no', msg_position='left')) == 'no': raise CommandCanceled() for b in ui.buffers: b.cleanup() if ui.db_was_locked: msg = 'Database locked. Exit without saving?' response = await ui.choice(msg, msg_position='left', cancel='no') if response == 'no': return # stop event loop if ui.dbman.ro: # do it now if DB is read only ui.exit() else: # trigger and wait for DB flush otherwise await ui.apply_command(FlushCommand(callback=ui.exit)) ui.cleanup() @registerCommand(MODE, 'search', usage='search query', arguments=[ (['--sort'], {'help': 'sort order', 'choices': [ 'oldest_first', 'newest_first', 'message_id', 'unsorted']}), (['query'], {'nargs': argparse.REMAINDER, 'help': 'search string'})]) class SearchCommand(Command): """open a new search buffer. Search obeys the notmuch :ref:`search.exclude_tags ` setting.""" repeatable = True def __init__(self, query, sort=None, **kwargs): """ :param query: notmuch querystring :type query: str :param sort: how to order results. Must be one of 'oldest_first', 'newest_first', 'message_id' or 'unsorted'. :type sort: str """ self.query = ' '.join(query) self.order = sort Command.__init__(self, **kwargs) def apply(self, ui): if self.query: open_searches = ui.get_buffers_of_type(buffers.SearchBuffer) to_be_focused = None for sb in open_searches: if sb.querystring == self.query: to_be_focused = sb if to_be_focused: if ui.current_buffer != to_be_focused: ui.buffer_focus(to_be_focused) else: # refresh an already displayed search ui.current_buffer.rebuild() ui.update() else: ui.buffer_open(buffers.SearchBuffer(ui, self.query, sort_order=self.order)) else: ui.notify('empty query string') @registerCommand(MODE, 'prompt', arguments=[ (['startwith'], {'nargs': '?', 'default': '', 'help': 'initial content'})]) class PromptCommand(Command): """prompts for commandline and interprets it upon select""" def __init__(self, startwith='', **kwargs): """ :param startwith: initial content of the prompt widget :type startwith: str """ self.startwith = startwith Command.__init__(self, **kwargs) async def apply(self, ui): logging.info('open command shell') mode = ui.mode or 'global' cmpl = CommandLineCompleter(ui.dbman, mode, ui.current_buffer) cmdline = await ui.prompt( '', text=self.startwith, completer=cmpl, history=ui.commandprompthistory) logging.debug('CMDLINE: %s', cmdline) # interpret and apply commandline if cmdline: # save into prompt history ui.commandprompthistory.append(cmdline) await ui.apply_commandline(cmdline) else: raise CommandCanceled() @registerCommand(MODE, 'refresh') class RefreshCommand(Command): """refresh the current buffer""" repeatable = True def apply(self, ui): ui.current_buffer.rebuild() ui.update() @registerCommand( MODE, 'shellescape', arguments=[ (['--spawn'], {'action': cargparse.BooleanAction, 'default': None, 'help': 'run in terminal window'}), (['--thread'], {'action': cargparse.BooleanAction, 'default': None, 'help': 'run in separate thread'}), (['--refocus'], {'action': cargparse.BooleanAction, 'help': 'refocus current buffer after command ' 'has finished'}), (['cmd'], {'help': 'command line to execute'})], forced={'shell': True}, ) class ExternalCommand(Command): """run external command""" repeatable = True def __init__(self, cmd, stdin=None, shell=False, spawn=False, refocus=True, thread=False, on_success=None, **kwargs): """ :param cmd: the command to call :type cmd: list or str :param stdin: input to pipe to the process :type stdin: file or str :param spawn: run command in a new terminal :type spawn: bool :param shell: let shell interpret command string :type shell: bool :param thread: run asynchronously, don't block alot :type thread: bool :param refocus: refocus calling buffer after cmd termination :type refocus: bool :param on_success: code to execute after command successfully exited :type on_success: callable """ logging.debug({'spawn': spawn}) # make sure cmd is a list of str if isinstance(cmd, str): # convert cmdstring to list: in case shell==True, # Popen passes only the first item in the list to $SHELL cmd = [cmd] if shell else split_commandstring(cmd) # determine complete command list to pass touchhook = settings.get_hook('touch_external_cmdlist') # filter cmd, shell and thread through hook if defined if touchhook is not None: logging.debug('calling hook: touch_external_cmdlist') res = touchhook(cmd, shell=shell, spawn=spawn, thread=thread) logging.debug('got: %s', res) cmd, shell, thread = res # otherwise if spawn requested and X11 is running elif spawn: if 'DISPLAY' in os.environ: term_cmd = settings.get('terminal_cmd', '') logging.info('spawn in terminal: %s', term_cmd) termcmdlist = split_commandstring(term_cmd) cmd = termcmdlist + cmd else: logging.warning('unable to handle spawn outside of X11 without touch_external_cmdlist hook set') thread = False self.cmdlist = cmd self.stdin = stdin self.shell = shell self.refocus = refocus self.in_thread = thread self.on_success = on_success Command.__init__(self, **kwargs) async def apply(self, ui): logging.debug('cmdlist: %s', self.cmdlist) callerbuffer = ui.current_buffer # set standard input for subcommand stdin = None if self.stdin is not None: # wrap strings in StrinIO so that they behaves like a file if isinstance(self.stdin, str): # XXX: is utf-8 always safe to use here, or do we need to check # the terminal encoding first? stdin = BytesIO(self.stdin.encode('utf-8')) else: stdin = self.stdin logging.info('calling external command: %s', self.cmdlist) err = None proc = None ret = '' # TODO: these can probably be refactored in terms of helper.call_cmd # and helper.call_cmd_async if self.in_thread: try: if self.shell: _cmd = asyncio.create_subprocess_shell # The shell function wants a single string or bytestring, # we could just join it, but lets be extra safe and use # shlex.quote to avoid suprises. cmdlist = [shlex.quote(' '.join(self.cmdlist))] else: _cmd = asyncio.create_subprocess_exec cmdlist = self.cmdlist proc = await _cmd( *cmdlist, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE if stdin else None) except OSError as e: ret = str(e) else: _, err = await proc.communicate(stdin.read() if stdin else None) if proc.returncode == 0: ret = 'success' elif err: ret = err.decode(urwid.util.detected_encoding) else: with ui.paused(): try: proc = subprocess.Popen( self.cmdlist, shell=self.shell, stdin=subprocess.PIPE if stdin else None, stderr=subprocess.PIPE) except OSError as e: ret = str(e) else: _, err = proc.communicate(stdin.read() if stdin else None) if proc and proc.returncode == 0: ret = 'success' elif err: ret = err.decode(urwid.util.detected_encoding) if ret == 'success': if self.on_success is not None: self.on_success() else: msg = "editor has exited with error code {} -- {}".format( "None" if proc is None else proc.returncode, ret or "No stderr output") ui.notify(msg, priority='error') if self.refocus and callerbuffer in ui.buffers: logging.info('refocussing') ui.buffer_focus(callerbuffer) class EditCommand(ExternalCommand): """edit a file""" def __init__(self, path, spawn=None, thread=None, **kwargs): """ :param path: path to the file to be edited :type path: str :param spawn: force running edtor in a new terminal :type spawn: bool :param thread: run asynchronously, don't block alot :type thread: bool """ self.spawn = spawn if spawn is None: self.spawn = settings.get('editor_spawn') self.thread = thread if thread is None: self.thread = settings.get('editor_in_thread') editor_cmdstring = None if os.path.isfile('/usr/bin/editor'): editor_cmdstring = '/usr/bin/editor' editor_cmdstring = os.environ.get('EDITOR', editor_cmdstring) editor_cmdstring = settings.get('editor_cmd') or editor_cmdstring logging.debug('using editor_cmd: %s', editor_cmdstring) self.cmdlist = None if editor_cmdstring: if '%s' in editor_cmdstring: cmdstring = editor_cmdstring.replace('%s', helper.shell_quote(path)) self.cmdlist = split_commandstring(cmdstring) else: self.cmdlist = split_commandstring(editor_cmdstring) + [path] logging.debug({'spawn: ': self.spawn, 'in_thread': self.thread}) ExternalCommand.__init__(self, self.cmdlist, spawn=self.spawn, thread=self.thread, **kwargs) async def apply(self, ui): if self.cmdlist is None: ui.notify('no editor set', priority='error') else: return await ExternalCommand.apply(self, ui) @registerCommand(MODE, 'pyshell') class PythonShellCommand(Command): """open an interactive python shell for introspection""" repeatable = True def apply(self, ui): with ui.paused(): code.interact(local=locals()) @registerCommand(MODE, 'repeat') class RepeatCommand(Command): """repeat the command executed last time""" def __init__(self, **kwargs): Command.__init__(self, **kwargs) async def apply(self, ui): if ui.last_commandline is not None: await ui.apply_commandline(ui.last_commandline) else: ui.notify('no last command') @registerCommand(MODE, 'call', arguments=[ (['command'], {'help': 'python command string to call'})]) class CallCommand(Command): """execute python code""" repeatable = True def __init__(self, command, **kwargs): """ :param command: python command string to call :type command: str """ Command.__init__(self, **kwargs) self.command = command async def apply(self, ui): try: hooks = settings.hooks if hooks: env = {'ui': ui, 'settings': settings} for k, v in env.items(): if not getattr(hooks, k, None): setattr(hooks, k, v) t = eval(self.command) if asyncio.iscoroutine(t): await t except Exception as e: logging.exception(e) msg = 'an error occurred during execution of "%s":\n%s' ui.notify(msg % (self.command, e), priority='error') @registerCommand(MODE, 'bclose', arguments=[ (['--redraw'], {'action': cargparse.BooleanAction, 'help': 'redraw current buffer after command has finished'}), (['--force'], {'action': 'store_true', 'help': 'never ask for confirmation'})]) class BufferCloseCommand(Command): """close a buffer""" repeatable = True def __init__(self, buffer=None, force=False, redraw=True, **kwargs): """ :param buffer: the buffer to close or None for current :type buffer: `alot.buffers.Buffer` :param force: force buffer close :type force: bool """ self.buffer = buffer self.force = force self.redraw = redraw Command.__init__(self, **kwargs) async def apply(self, ui): async def one_buffer(prompt=True): """Helper to handle the case on only one buffer being opened. prompt is a boolean that is passed to ExitCommand() as the _prompt keyword argument. """ # If there is only one buffer and the settings don't allow using # closebuffer to exit, then just stop. if not settings.get('quit_on_last_bclose'): msg = ('not closing last remaining buffer as ' 'global.quit_on_last_bclose is set to False') logging.info(msg) ui.notify(msg, priority='error') # Otherwise pass directly to exit command, which also prommpts for # 'close without sending' else: logging.info('closing the last buffer, exiting') await ui.apply_command(ExitCommand(_prompt=prompt)) if self.buffer is None: self.buffer = ui.current_buffer if len(ui.buffers) == 1: await one_buffer() return if (isinstance(self.buffer, buffers.EnvelopeBuffer) and not self.buffer.envelope.sent_time): msg = 'close without sending?' if (not self.force and (await ui.choice(msg, cancel='no', msg_position='left')) == 'no'): raise CommandCanceled() # Because we await above it is possible that the settings or the number # of buffers chould change, so retest. if len(ui.buffers) == 1: await one_buffer(prompt=False) else: ui.buffer_close(self.buffer, self.redraw) @registerCommand(MODE, 'bprevious', forced={'offset': -1}, help='focus previous buffer') @registerCommand(MODE, 'bnext', forced={'offset': +1}, help='focus next buffer') @registerCommand( MODE, 'buffer', arguments=[(['index'], {'type': int, 'help': 'buffer index to focus'})], help='focus buffer with given index') class BufferFocusCommand(Command): """focus a :class:`~alot.buffers.Buffer`""" repeatable = True def __init__(self, buffer=None, index=None, offset=0, **kwargs): """ :param buffer: the buffer to focus or None :type buffer: `alot.buffers.Buffer` :param index: index (in bufferlist) of the buffer to focus. :type index: int :param offset: position of the buffer to focus relative to the currently focussed one. This is used only if `buffer` is set to `None` :type offset: int """ self.buffer = buffer self.index = index self.offset = offset Command.__init__(self, **kwargs) def apply(self, ui): if self.buffer is None: if self.index is not None: try: self.buffer = ui.buffers[self.index] except IndexError: ui.notify('no buffer exists at index %d' % self.index) return else: self.index = ui.buffers.index(ui.current_buffer) num = len(ui.buffers) self.buffer = ui.buffers[(self.index + self.offset) % num] ui.buffer_focus(self.buffer) @registerCommand(MODE, 'bufferlist') class OpenBufferlistCommand(Command): """open a list of active buffers""" def __init__(self, filtfun=lambda x: x, **kwargs): """ :param filtfun: filter to apply to displayed list :type filtfun: callable (str->bool) """ self.filtfun = filtfun Command.__init__(self, **kwargs) def apply(self, ui): blists = ui.get_buffers_of_type(buffers.BufferlistBuffer) if blists: ui.buffer_focus(blists[0]) else: bl = buffers.BufferlistBuffer(ui, self.filtfun) ui.buffer_open(bl) @registerCommand(MODE, 'taglist', arguments=[ (['--tags'], {'nargs': '+', 'help': 'tags to display'}), ]) class TagListCommand(Command): """opens taglist buffer""" def __init__(self, filtfun=lambda x: x, tags=None, **kwargs): """ :param filtfun: filter to apply to displayed list :type filtfun: callable (str->bool) """ self.filtfun = filtfun self.tags = tags Command.__init__(self, **kwargs) def apply(self, ui): tags = self.tags or ui.dbman.get_all_tags() blists = ui.get_buffers_of_type(buffers.TagListBuffer) if blists: buf = blists[0] buf.tags = tags buf.rebuild() ui.buffer_focus(buf) else: ui.buffer_open(buffers.TagListBuffer(ui, tags, self.filtfun)) @registerCommand(MODE, 'namedqueries') class NamedQueriesCommand(Command): """opens named queries buffer""" def __init__(self, filtfun=bool, **kwargs): """ :param filtfun: filter to apply to displayed list :type filtfun: callable (str->bool) """ self.filtfun = filtfun Command.__init__(self, **kwargs) def apply(self, ui): ui.buffer_open(buffers.NamedQueriesBuffer(ui, self.filtfun)) @registerCommand(MODE, 'flush') class FlushCommand(Command): """flush write operations or retry until committed""" repeatable = True def __init__(self, callback=None, silent=False, **kwargs): """ :param callback: function to call after successful writeout :type callback: callable """ Command.__init__(self, **kwargs) self.callback = callback self.silent = silent def apply(self, ui): try: ui.dbman.flush() if callable(self.callback): self.callback() logging.debug('flush complete') if ui.db_was_locked: if not self.silent: ui.notify('changes flushed') ui.db_was_locked = False ui.update() except DatabaseLockedError: timeout = settings.get('flush_retry_timeout') if timeout > 0: def f(*_): self.apply(ui) ui.mainloop.set_alarm_in(timeout, f) if not ui.db_was_locked: if not self.silent: ui.notify('index locked, will try again in %d secs' % timeout) ui.db_was_locked = True ui.update() return # TODO: choices @registerCommand(MODE, 'help', arguments=[ (['commandname'], {'help': 'command or \'bindings\''})]) class HelpCommand(Command): """display help for a command (use \'bindings\' to display all keybindings interpreted in current mode)""" def __init__(self, commandname='', **kwargs): """ :param commandname: command to document :type commandname: str """ Command.__init__(self, **kwargs) self.commandname = commandname def apply(self, ui): logging.debug('HELP') if self.commandname == 'bindings': text_att = settings.get_theming_attribute('help', 'text') title_att = settings.get_theming_attribute('help', 'title') section_att = settings.get_theming_attribute('help', 'section') # get mappings globalmaps, modemaps = settings.get_keybindings(ui.mode) # build table maxkeylength = len( max(list(modemaps.keys()) + list(globalmaps.keys()), key=len)) keycolumnwidth = maxkeylength + 2 linewidgets = [] # mode specific maps if modemaps: txt = (section_att, '\n%s-mode specific maps' % ui.mode) linewidgets.append(urwid.Text(txt)) for (k, v) in modemaps.items(): line = urwid.Columns([('fixed', keycolumnwidth, urwid.Text((text_att, k))), urwid.Text((text_att, v))]) linewidgets.append(line) # global maps linewidgets.append(urwid.Text((section_att, '\nglobal maps'))) for (k, v) in globalmaps.items(): if k not in modemaps: line = urwid.Columns( [('fixed', keycolumnwidth, urwid.Text((text_att, k))), urwid.Text((text_att, v))]) linewidgets.append(line) body = urwid.ListBox(linewidgets) titletext = 'Bindings Help (escape cancels)' box = DialogBox(body, titletext, bodyattr=text_att, titleattr=title_att) # put promptwidget as overlay on main widget overlay = urwid.Overlay(box, ui.root_widget, 'center', ('relative', 70), 'middle', ('relative', 70)) ui.show_as_root_until_keypress(overlay, 'esc') else: logging.debug('HELP %s', self.commandname) parser = commands.lookup_parser(self.commandname, ui.mode) if parser: ui.notify(parser.format_help(), block=True) else: ui.notify('command not known: %s' % self.commandname, priority='error') @registerCommand(MODE, 'compose', arguments=[ (['--sender'], {'nargs': '?', 'help': 'sender'}), (['--template'], {'nargs': '?', 'help': 'path to a template message file'}), (['--tags'], {'nargs': '?', 'help': 'comma-separated list of tags to apply to message'}), (['--subject'], {'nargs': '?', 'help': 'subject line'}), (['--to'], {'nargs': '+', 'help': 'recipients'}), (['--cc'], {'nargs': '+', 'help': 'copy to'}), (['--bcc'], {'nargs': '+', 'help': 'blind copy to'}), (['--attach'], {'nargs': '+', 'help': 'attach files'}), (['--omit_signature'], {'action': 'store_true', 'help': 'do not add signature'}), (['--spawn'], {'action': cargparse.BooleanAction, 'default': None, 'help': 'spawn editor in new terminal'}), (['rest'], {'nargs': '*'}), ]) class ComposeCommand(Command): """compose a new email""" def __init__( self, envelope=None, headers=None, template=None, sender='', tags=None, subject='', to=None, cc=None, bcc=None, attach=None, omit_signature=False, spawn=None, rest=None, encrypt=False, **kwargs): """ :param envelope: use existing envelope :type envelope: :class:`~alot.db.envelope.Envelope` :param headers: forced header values :type headers: dict (str->str) :param template: name of template to parse into the envelope after creation. This should be the name of a file in your template_dir :type template: str :param sender: From-header value :type sender: str :param tags: Comma-separated list of tags to apply to message :type tags: list(str) :param subject: Subject-header value :type subject: str :param to: To-header value :type to: str :param cc: Cc-header value :type cc: str :param bcc: Bcc-header value :type bcc: str :param attach: Path to files to be attached (globable) :type attach: str :param omit_signature: do not attach/append signature :type omit_signature: bool :param spawn: force spawning of editor in a new terminal :type spawn: bool :param rest: remaining parameters. These can start with 'mailto' in which case it is interpreted as mailto string. Otherwise it will be interpreted as recipients (to) header :type rest: list(str) :param encrypt: if the email should be encrypted :type encrypt: bool """ Command.__init__(self, **kwargs) self.envelope = envelope self.template = template self.headers = headers or {} self.sender = sender self.subject = subject self.to = to or [] self.cc = cc or [] self.bcc = bcc or [] self.attach = attach self.omit_signature = omit_signature self.force_spawn = spawn self.rest = ' '.join(rest or []) self.encrypt = encrypt self.tags = tags class ApplyError(Exception): pass def _get_template(self, ui): # get location of tempsdir, containing msg templates tempdir = settings.get('template_dir') path = os.path.expanduser(self.template) if not os.path.dirname(path): # use tempsdir if not os.path.isdir(tempdir): ui.notify('no templates directory: %s' % tempdir, priority='error') raise self.ApplyError() path = os.path.join(tempdir, path) if not os.path.isfile(path): ui.notify('could not find template: %s' % path, priority='error') raise self.ApplyError() try: with open(path, 'rb') as f: template = helper.try_decode(f.read()) self.envelope.parse_template(template) except Exception as e: ui.notify(str(e), priority='error') raise self.ApplyError() async def _get_sender_details(self, ui): # find out the right account, if possible yet account = self.envelope.account if account is None: accounts = settings.get_accounts() if not accounts: ui.notify('no accounts set.', priority='error') return elif len(accounts) == 1: account = accounts[0] # get missing From header if 'From' not in self.envelope.headers: if account is not None: fromstring = email.utils.formataddr( (account.realname, str(account.address))) self.envelope.add('From', fromstring) else: cmpl = AccountCompleter() fromaddress = await ui.prompt('From', completer=cmpl, tab=1, history=ui.senderhistory) if fromaddress is None: raise CommandCanceled() ui.senderhistory.append(fromaddress) self.envelope.add('From', fromaddress) else: fromaddress = self.envelope.get("From") # try to find the account again if account is None: try: account = settings.account_matching_address(fromaddress) except NoMatchingAccount: msg = 'Cannot compose mail - ' \ 'no account found for `%s`' % fromaddress logging.error(msg) ui.notify(msg, priority='error') raise CommandCanceled() if self.envelope.account is None: self.envelope.account = account async def _set_signature(self, ui): account = self.envelope.account if not self.omit_signature and account.signature: logging.debug('has signature') sig = os.path.expanduser(account.signature) if os.path.isfile(sig): logging.debug('is file') if account.signature_as_attachment: name = account.signature_filename or None self.envelope.attach(sig, filename=name) logging.debug('attached') else: with open(sig, 'rb') as f: sigcontent = f.read() mimetype = helper.guess_mimetype(sigcontent) if mimetype.startswith('text'): sigcontent = helper.try_decode(sigcontent) self.envelope.body_txt += '\n' + sigcontent else: ui.notify('could not locate signature: %s' % sig, priority='error') if (await ui.choice('send without signature?', 'yes', 'no')) == 'no': raise self.ApplyError async def apply(self, ui): try: await self.__apply(ui) except self.ApplyError: return def _get_account(self, ui): # find out the right account sender = self.envelope.get('From') _, addr = email.utils.parseaddr(sender) try: account = settings.get_account_by_address(addr) except NoMatchingAccount: msg = 'Cannot compose mail - no account found for `%s`' % addr logging.error(msg) ui.notify(msg, priority='error') raise CommandCanceled() if account is None: accounts = settings.get_accounts() if not accounts: ui.notify('no accounts set.', priority='error') raise self.ApplyError account = accounts[0] return account def _set_envelope(self): if self.envelope is None: if self.rest: if self.rest.startswith('mailto'): self.envelope = mailto_to_envelope(self.rest) else: self.envelope = Envelope() self.envelope.add('To', self.rest) else: self.envelope = Envelope() def _set_gpg_sign(self, ui): account = self.envelope.account if account.sign_by_default: if account.gpg_key: self.envelope.sign = account.sign_by_default self.envelope.sign_key = account.gpg_key else: msg = 'Cannot find gpg key for account {}' msg = msg.format(account.address) logging.warning(msg) ui.notify(msg, priority='error') async def _set_to(self, ui): account = self.envelope.account if 'To' not in self.envelope.headers: allbooks = not settings.get('complete_matching_abook_only') logging.debug(allbooks) abooks = settings.get_addressbooks(order=[account], append_remaining=allbooks) logging.debug(abooks) completer = ContactsCompleter(abooks) to = await ui.prompt('To', completer=completer, history=ui.recipienthistory) if to is None: raise CommandCanceled() to = to.strip(' \t\n,') ui.recipienthistory.append(to) self.envelope.add('To', to) async def _set_gpg_encrypt(self, ui): account = self.envelope.account if self.encrypt or account.encrypt_by_default == "all": logging.debug("Trying to encrypt message because encrypt=%s and " "encrypt_by_default=%s", self.encrypt, account.encrypt_by_default) await update_keys(ui, self.envelope, block_error=self.encrypt) elif account.encrypt_by_default == "trusted": logging.debug("Trying to encrypt message because " "account.encrypt_by_default=%s", account.encrypt_by_default) await update_keys(ui, self.envelope, block_error=self.encrypt, signed_only=True) else: logging.debug("No encryption by default, encrypt_by_default=%s", account.encrypt_by_default) def _set_base_attributes(self): # set forced headers for key, value in self.headers.items(): self.envelope.add(key, value) # set forced headers for separate parameters if self.sender: self.envelope.add('From', self.sender) if self.subject: self.envelope.add('Subject', self.subject) if self.to: self.envelope.add('To', ','.join(self.to)) if self.cc: self.envelope.add('Cc', ','.join(self.cc)) if self.bcc: self.envelope.add('Bcc', ','.join(self.bcc)) if self.tags: self.envelope.tags = [t for t in self.tags.split(',') if t] async def _set_subject(self, ui): if settings.get('ask_subject') and \ 'Subject' not in self.envelope.headers: subject = await ui.prompt('Subject') logging.debug('SUBJECT: "%s"', subject) if subject is None: raise CommandCanceled() self.envelope.add('Subject', subject) async def _set_compose_tags(self, ui): if settings.get('compose_ask_tags'): comp = TagsCompleter(ui.dbman) tags = ','.join(self.tags) if self.tags else '' tagsstring = await ui.prompt('Tags', text=tags, completer=comp) tags = [t for t in tagsstring.split(',') if t] if tags is None: raise CommandCanceled() self.envelope.tags = tags def _set_attachments(self): if self.attach: for gpath in self.attach: for a in glob.glob(gpath): self.envelope.attach(a) logging.debug('attaching: %s', a) async def __apply(self, ui): self._set_envelope() if self.template is not None: self._get_template(ui) # Set headers and tags self._set_base_attributes() # set account and missing From header await self._get_sender_details(ui) # add signature await self._set_signature(ui) # Figure out whether we should GPG sign messages by default # and look up key if so self._set_gpg_sign(ui) # get missing To header await self._set_to(ui) # Set subject await self._set_subject(ui) # Add additional tags await self._set_compose_tags(ui) # Set attachments self._set_attachments() # set encryption if needed await self._set_gpg_encrypt(ui) cmd = commands.envelope.EditCommand(envelope=self.envelope, spawn=self.force_spawn, refocus=False) await ui.apply_command(cmd) @registerCommand( MODE, 'move', help='move focus in current buffer', arguments=[ (['movement'], {'nargs': argparse.REMAINDER, 'help': 'up, down, [half]page up, [half]page down, first, last'})]) class MoveCommand(Command): """move in widget""" def __init__(self, movement=None, **kwargs): if movement is None: self.movement = '' else: self.movement = ' '.join(movement) Command.__init__(self, **kwargs) def apply(self, ui): if self.movement in ['up', 'down', 'page up', 'page down']: ui.mainloop.process_input([self.movement]) elif self.movement in ['halfpage down', 'halfpage up']: ui.mainloop.process_input( ui.mainloop.screen_size[1] // 2 * [self.movement.split()[-1]]) elif self.movement == 'first': if hasattr(ui.current_buffer, "focus_first"): ui.current_buffer.focus_first() ui.update() elif self.movement == 'last': if hasattr(ui.current_buffer, "focus_last"): ui.current_buffer.focus_last() ui.update() else: ui.notify('unknown movement: ' + self.movement, priority='error') @registerCommand(MODE, 'reload', help='reload all configuration files') class ReloadCommand(Command): """Reload configuration.""" def apply(self, ui): try: settings.reload() except ConfigError as e: ui.notify('Error when reloading config files:\n {}'.format(e), priority='error') @registerCommand( MODE, 'savequery', arguments=[ (['--no-flush'], {'action': 'store_false', 'dest': 'flush', 'default': 'True', 'help': 'postpone a writeout to the index'}), (['alias'], {'help': 'alias to use for query string'}), (['query'], {'help': 'query string to store', 'nargs': '+'}) ], help='store query string as a "named query" in the database') class SaveQueryCommand(Command): """save alias for query string""" repeatable = False def __init__(self, alias, query=None, flush=True, **kwargs): """ :param alias: name to use for query string :type alias: str :param query: query string to save :type query: str or None :param flush: immediately write out to the index :type flush: bool """ self.alias = alias if query is None: self.query = '' else: self.query = ' '.join(query) self.flush = flush Command.__init__(self, **kwargs) def apply(self, ui): msg = 'saved alias "%s" for query string "%s"' % (self.alias, self.query) try: ui.dbman.save_named_query(self.alias, self.query) logging.debug(msg) ui.notify(msg) except DatabaseROError: ui.notify('index in read-only mode', priority='error') return # flush index if self.flush: ui.apply_command(commands.globals.FlushCommand()) @registerCommand( MODE, 'removequery', arguments=[ (['--no-flush'], {'action': 'store_false', 'dest': 'flush', 'default': 'True', 'help': 'postpone a writeout to the index'}), (['alias'], {'help': 'alias to remove'}), ], help='removes a "named query" from the database') class RemoveQueryCommand(Command): """remove named query string for given alias""" repeatable = False def __init__(self, alias, flush=True, **kwargs): """ :param alias: name to use for query string :type alias: str :param flush: immediately write out to the index :type flush: bool """ self.alias = alias self.flush = flush Command.__init__(self, **kwargs) def apply(self, ui): msg = 'removed alias "%s"' % (self.alias) try: ui.dbman.remove_named_query(self.alias) logging.debug(msg) ui.notify(msg) except DatabaseROError: ui.notify('index in read-only mode', priority='error') return # flush index if self.flush: ui.apply_command(commands.globals.FlushCommand()) @registerCommand( MODE, 'confirmsequence', arguments=[ (['msg'], {'help': 'Additional message to prompt', 'nargs': '*'}) ], help="prompt to confirm a sequence of commands") class ConfirmCommand(Command): """Prompt user to confirm a sequence of commands.""" def __init__(self, msg=None, **kwargs): """ :param msg: Additional message to prompt the user with :type msg: List[str] """ super(ConfirmCommand, self).__init__(**kwargs) if not msg: self.msg = "Confirm sequence?" else: self.msg = "Confirm sequence: {}?".format(" ".join(msg)) async def apply(self, ui): if (await ui.choice(self.msg, select='yes', cancel='no', msg_position='left')) == 'no': raise SequenceCanceled() alot-0.11/alot/commands/namedqueries.py000066400000000000000000000016511466311112200201660ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import argparse from . import Command, registerCommand from .globals import SearchCommand MODE = 'namedqueries' @registerCommand(MODE, 'select', arguments=[ (['filt'], {'nargs': argparse.REMAINDER, 'help': 'additional filter to apply to query'}), ]) class NamedqueriesSelectCommand(Command): """search for messages with selected query""" def __init__(self, filt=None, **kwargs): self._filt = filt Command.__init__(self, **kwargs) async def apply(self, ui): query_name = ui.current_buffer.get_selected_query() query = ['query:"%s"' % query_name] if self._filt: query.extend(['and'] + self._filt) cmd = SearchCommand(query=query) await ui.apply_command(cmd) alot-0.11/alot/commands/search.py000066400000000000000000000241461466311112200167550ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # Copyright © 2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import argparse import logging from . import Command, registerCommand from .globals import PromptCommand from .globals import MoveCommand from .globals import SaveQueryCommand as GlobalSaveQueryCommand from .common import RetagPromptCommand from .. import commands from .. import buffers from ..db.errors import DatabaseROError from ..settings.const import settings MODE = 'search' @registerCommand( MODE, 'select', arguments=[ (['--all-folded'], {'action': 'store_true', 'dest': 'all_folded', 'help': 'do not unfold matching messages'}), ], ) class OpenThreadCommand(Command): """open thread in a new buffer""" def __init__(self, thread=None, all_folded=False, **kwargs): """ :param thread: thread to open (Uses focussed thread if unset) :type thread: :class:`~alot.db.Thread` """ self.thread = thread self.all_folded = all_folded Command.__init__(self, **kwargs) def apply(self, ui): if not self.thread: self.thread = ui.current_buffer.get_selected_thread() if self.thread: query = settings.get('thread_unfold_matching') if not query: query = ui.current_buffer.querystring logging.info('open thread view for %s', self.thread) tb = buffers.ThreadBuffer(ui, self.thread) ui.buffer_open(tb) if not self.all_folded: tb.unfold_matching(query) @registerCommand(MODE, 'refine', help='refine query', arguments=[ (['--sort'], {'help': 'sort order', 'choices': [ 'oldest_first', 'newest_first', 'message_id', 'unsorted']}), (['query'], {'nargs': argparse.REMAINDER, 'help': 'search string'})]) @registerCommand(MODE, 'sort', help='set sort order', arguments=[ (['sort'], {'help': 'sort order', 'choices': [ 'oldest_first', 'newest_first', 'message_id', 'unsorted']}), ]) class RefineCommand(Command): """refine the querystring of this buffer""" def __init__(self, query=None, sort=None, **kwargs): """ :param query: new querystring given as list of strings as returned by argparse :type query: list of str """ if query is None: self.querystring = None else: self.querystring = ' '.join(query) self.sort_order = sort Command.__init__(self, **kwargs) def apply(self, ui): if self.querystring or self.sort_order: sbuffer = ui.current_buffer oldquery = sbuffer.querystring if self.querystring not in [None, oldquery]: sbuffer.querystring = self.querystring sbuffer = ui.current_buffer if self.sort_order: sbuffer.sort_order = self.sort_order sbuffer.rebuild() ui.update() else: ui.notify('empty query string') @registerCommand(MODE, 'refineprompt') class RefinePromptCommand(Command): """prompt to change this buffers querystring""" repeatable = True async def apply(self, ui): sbuffer = ui.current_buffer oldquery = sbuffer.querystring return await ui.apply_command(PromptCommand('refine ' + oldquery)) RetagPromptCommand = registerCommand(MODE, 'retagprompt')(RetagPromptCommand) @registerCommand( MODE, 'tag', forced={'action': 'add'}, arguments=[ (['--no-flush'], {'action': 'store_false', 'dest': 'flush', 'default': 'True', 'help': 'postpone a writeout to the index'}), (['--all'], {'action': 'store_true', 'dest': 'allmessages', 'default': False, 'help': 'tag all messages that match the current search query'}), (['tags'], {'help': 'comma separated list of tags'})], help='add tags to all messages in the selected thread', ) @registerCommand( MODE, 'retag', forced={'action': 'set'}, arguments=[ (['--no-flush'], {'action': 'store_false', 'dest': 'flush', 'default': 'True', 'help': 'postpone a writeout to the index'}), (['--all'], {'action': 'store_true', 'dest': 'allmessages', 'default': False, 'help': 'retag all messages that match the current query'}), (['tags'], {'help': 'comma separated list of tags'})], help='set tags to all messages in the selected thread', ) @registerCommand( MODE, 'untag', forced={'action': 'remove'}, arguments=[ (['--no-flush'], {'action': 'store_false', 'dest': 'flush', 'default': 'True', 'help': 'postpone a writeout to the index'}), (['--all'], {'action': 'store_true', 'dest': 'allmessages', 'default': False, 'help': 'untag all messages that match the current query'}), (['tags'], {'help': 'comma separated list of tags'})], help='remove tags from all messages in the selected thread', ) @registerCommand( MODE, 'toggletags', forced={'action': 'toggle'}, arguments=[ (['--no-flush'], {'action': 'store_false', 'dest': 'flush', 'default': 'True', 'help': 'postpone a writeout to the index'}), (['tags'], {'help': 'comma separated list of tags'})], help='flip presence of tags on the selected thread: a tag is considered present ' 'and will be removed if at least one message in this thread is ' 'tagged with it') class TagCommand(Command): """manipulate message tags""" repeatable = True def __init__(self, tags='', action='add', allmessages=False, flush=True, **kwargs): """ :param tags: comma separated list of tagstrings to set :type tags: str :param action: adds tags if 'add', removes them if 'remove', adds tags and removes all other if 'set' or toggle individually if 'toggle' :type action: str :param allmessages: tag all messages in search result :type allmessages: bool :param flush: immediately write out to the index :type flush: bool """ self.tagsstring = tags self.action = action self.allm = allmessages self.flush = flush Command.__init__(self, **kwargs) async def apply(self, ui): searchbuffer = ui.current_buffer threadline_widget = searchbuffer.get_selected_threadline() # pass if the current buffer has no selected threadline # (displays an empty search result) if threadline_widget is None: return testquery = searchbuffer.querystring thread = threadline_widget.get_thread() if not self.allm: testquery = "thread:%s" % thread.get_thread_id() logging.debug('all? %s', self.allm) logging.debug('q: %s', testquery) def refresh(): # update total result count if not self.allm: # remove thread from resultset if it doesn't match the search query # any more and refresh selected threadline otherwise countquery = "(%s) AND thread:%s" % (searchbuffer.querystring, thread.get_thread_id()) hitcount_after = ui.dbman.count_messages(countquery) if hitcount_after == 0: logging.debug('remove thread from result list: %s', thread) if threadline_widget in searchbuffer.threadlist: # remove this thread from result list searchbuffer.threadlist.remove(threadline_widget) else: threadline_widget.rebuild() searchbuffer.result_count = searchbuffer.dbman.count_messages( searchbuffer.querystring) else: searchbuffer.rebuild() ui.update() tags = [x for x in self.tagsstring.split(',') if x] try: if self.action == 'add': ui.dbman.tag(testquery, tags, remove_rest=False) if self.action == 'set': ui.dbman.tag(testquery, tags, remove_rest=True) elif self.action == 'remove': ui.dbman.untag(testquery, tags) elif self.action == 'toggle': if not self.allm: ui.dbman.toggle_tags(testquery, tags, afterwards=refresh) except DatabaseROError: ui.notify('index in read-only mode', priority='error') return # flush index if self.flush: await ui.apply_command( commands.globals.FlushCommand(callback=refresh)) @registerCommand( MODE, 'move', help='move focus in search buffer', arguments=[(['movement'], {'nargs': argparse.REMAINDER, 'help': 'last'})]) class MoveFocusCommand(MoveCommand): def apply(self, ui): logging.debug(self.movement) if self.movement == 'last': ui.current_buffer.focus_last() ui.update() else: MoveCommand.apply(self, ui) @registerCommand( MODE, 'savequery', arguments=[ (['--no-flush'], {'action': 'store_false', 'dest': 'flush', 'default': 'True', 'help': 'postpone a writeout to the index'}), (['alias'], {'help': 'alias to use for query string'}), (['query'], {'help': 'query string to store', 'nargs': argparse.REMAINDER, }), ], help='store query string as a "named query" in the database. ' 'This falls back to the current search query in search buffers.') class SaveQueryCommand(GlobalSaveQueryCommand): def apply(self, ui): searchbuffer = ui.current_buffer if not self.query: self.query = searchbuffer.querystring GlobalSaveQueryCommand.apply(self, ui) alot-0.11/alot/commands/taglist.py000066400000000000000000000011331466311112200171460ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # Copyright © 2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from . import Command, registerCommand from .globals import SearchCommand MODE = 'taglist' @registerCommand(MODE, 'select') class TaglistSelectCommand(Command): """search for messages with selected tag""" async def apply(self, ui): tagstring = ui.current_buffer.get_selected_tag() cmd = SearchCommand(query=['tag:"%s"' % tagstring]) await ui.apply_command(cmd) alot-0.11/alot/commands/thread.py000066400000000000000000001352231466311112200167560ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # Copyright © 2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import argparse import logging import mailcap import os import subprocess import tempfile import email import email.policy from email.utils import getaddresses, parseaddr from email.message import Message import urwid from io import BytesIO from . import Command, registerCommand from .globals import ExternalCommand from .globals import FlushCommand from .globals import ComposeCommand from .globals import MoveCommand from .globals import CommandCanceled from .common import RetagPromptCommand from .envelope import SendCommand from ..completion.contacts import ContactsCompleter from ..completion.path import PathCompleter from ..db.utils import decode_header from ..db.utils import formataddr from ..db.utils import get_body_part from ..db.utils import extract_headers from ..db.utils import clear_my_address from ..db.utils import ensure_unique_address from ..db.envelope import Envelope from ..db.attachment import Attachment from ..db.errors import DatabaseROError from ..settings.const import settings from ..helper import parse_mailcap_nametemplate from ..helper import split_commandstring from ..utils import argparse as cargparse from ..utils import ansi from ..widgets.globals import AttachmentWidget MODE = 'thread' def determine_sender(mail, action='reply'): """ Inspect a given mail to reply/forward/bounce and find the most appropriate account to act from and construct a suitable From-Header to use. :param mail: the email to inspect :type mail: `email.message.Message` :param action: intended use case: one of "reply", "forward" or "bounce" :type action: str """ assert action in ['reply', 'forward', 'bounce'] # get accounts my_accounts = settings.get_accounts() assert my_accounts, 'no accounts set!' # extract list of addresses to check for my address # X-Envelope-To and Envelope-To are used to store the recipient address # if not included in other fields # Process the headers in order of importance: if a mail was sent with # account X, with account Y in e.g. CC or delivered-to, make sure that # account X is the one selected and not account Y. candidate_headers = settings.get("reply_account_header_priority") for candidate_header in candidate_headers: candidate_addresses = getaddresses(mail.get_all(candidate_header, [])) logging.debug('candidate addresses: %s', candidate_addresses) # pick the most important account that has an address in candidates # and use that account's realname and the address found here for account in my_accounts: for seen_name, seen_address in candidate_addresses: if account.matches_address(seen_address): if settings.get(action + '_force_realname'): realname = account.realname else: realname = seen_name if settings.get(action + '_force_address'): address = str(account.address) else: address = seen_address logging.debug('using realname: "%s"', realname) logging.debug('using address: %s', address) from_value = formataddr((realname, address)) return from_value, account # revert to default account if nothing found account = my_accounts[0] realname = account.realname address = account.address logging.debug('using realname: "%s"', realname) logging.debug('using address: %s', address) from_value = formataddr((realname, str(address))) return from_value, account @registerCommand(MODE, 'reply', arguments=[ (['--all'], {'action': 'store_true', 'help': 'reply to all'}), (['--list'], {'action': cargparse.BooleanAction, 'default': None, 'dest': 'listreply', 'help': 'reply to list'}), (['--spawn'], {'action': cargparse.BooleanAction, 'default': None, 'help': 'open editor in new window'})]) class ReplyCommand(Command): """reply to message""" repeatable = True def __init__(self, message=None, all=False, listreply=None, spawn=None, **kwargs): """ :param message: message to reply to (defaults to selected message) :type message: `alot.db.message.Message` :param all: group reply; copies recipients from Bcc/Cc/To to the reply :type all: bool :param listreply: reply to list; autodetect if unset and enabled in config :type listreply: bool :param spawn: force spawning of editor in a new terminal :type spawn: bool """ self.message = message self.groupreply = all self.listreply = listreply self.force_spawn = spawn Command.__init__(self, **kwargs) async def apply(self, ui): # get message to reply to if not given in constructor if not self.message: self.message = ui.current_buffer.get_selected_message() mail = self.message.get_email() # set body text name, address = parseaddr(mail['From']) timestamp = self.message.get_date() qf = settings.get_hook('reply_prefix') if qf: quotestring = qf(name, address, timestamp, message=mail, ui=ui, dbm=ui.dbman) else: quotestring = 'Quoting %s (%s)\n' % (name or address, timestamp) mailcontent = quotestring quotehook = settings.get_hook('text_quote') body_text = ansi.remove_csi(self.message.get_body_text()) if quotehook: mailcontent += quotehook(body_text) else: quote_prefix = settings.get('quote_prefix') for line in body_text.splitlines(): mailcontent += quote_prefix + line + '\n' envelope = Envelope(bodytext=mailcontent, replied=self.message) # copy subject subject = decode_header(mail.get('Subject', '')) reply_subject_hook = settings.get_hook('reply_subject') if reply_subject_hook: subject = reply_subject_hook(subject) else: rsp = settings.get('reply_subject_prefix') if not subject.lower().startswith(('re:', rsp.lower())): subject = rsp + subject envelope.add('Subject', subject) # Auto-detect ML auto_replyto_mailinglist = settings.get('auto_replyto_mailinglist') if mail['List-Id'] and self.listreply is None: # mail['List-Id'] is need to enable reply-to-list self.listreply = auto_replyto_mailinglist elif mail['List-Id'] and self.listreply is True: self.listreply = True elif self.listreply is False: # In this case we only need the sender self.listreply = False # set From-header and sending account try: from_header, account = determine_sender(mail, 'reply') except AssertionError as e: ui.notify(str(e), priority='error') return envelope.add('From', from_header) envelope.account = account # set To sender = mail['Reply-To'] or mail['From'] sender_address = parseaddr(sender)[1] cc = [] # check if reply is to self sent message if account.matches_address(sender_address): recipients = mail.get_all('To', []) emsg = 'Replying to own message, set recipients to: %s' \ % recipients logging.debug(emsg) else: recipients = [sender] if self.groupreply: # make sure that our own address is not included # if the message was self-sent, then our address is not included MFT = mail.get_all('Mail-Followup-To', []) followupto = clear_my_address(account, MFT) if followupto and settings.get('honor_followup_to'): logging.debug('honor followup to: %s', ', '.join(followupto)) recipients = followupto # since Mail-Followup-To was set, ignore the Cc header else: if sender != mail['From']: recipients.append(mail['From']) # append To addresses if not replying to self sent message if not account.matches_address(sender_address): cleared = clear_my_address(account, mail.get_all('To', [])) recipients.extend(cleared) # copy cc for group-replies if 'Cc' in mail: cc = clear_my_address(account, mail.get_all('Cc', [])) envelope.add('Cc', decode_header(', '.join(cc))) to = ', '.join(ensure_unique_address(recipients)) logging.debug('reply to: %s', to) if self.listreply: # To choose the target of the reply --list # Reply-To is standart reply target RFC 2822:, RFC 1036: 2.2.1 # X-BeenThere is needed by sourceforge ML also winehq # X-Mailing-List is also standart and is used by git-send-mail to = mail['Reply-To'] or mail['X-BeenThere'] or mail['X-Mailing-List'] # Some mail server (gmail) will not resend you own mail, so you # have to deal with the one in sent if to is None: to = mail['To'] logging.debug('mail list reply to: %s', to) # Cleaning the 'To' in this case if envelope.get('To') is not None: envelope.__delitem__('To') # Finally setup the 'To' header envelope.add('To', decode_header(to)) # if any of the recipients is a mailinglist that we are subscribed to, # set Mail-Followup-To header so that duplicates are avoided if settings.get('followup_to'): # to and cc are already cleared of our own address allrecipients = [to] + cc lists = settings.get('mailinglists') # check if any recipient address matches a known mailing list if any(addr in lists for n, addr in getaddresses(allrecipients)): followupto = ', '.join(allrecipients) logging.debug('mail followup to: %s', followupto) envelope.add('Mail-Followup-To', decode_header(followupto)) # set In-Reply-To header envelope.add('In-Reply-To', '<%s>' % self.message.get_message_id()) # set References header old_references = mail.get('References', '') if old_references: old_references = old_references.split() references = old_references[-8:] if len(old_references) > 8: references = old_references[:1] + references references.append('<%s>' % self.message.get_message_id()) envelope.add('References', ' '.join(references)) else: envelope.add('References', '<%s>' % self.message.get_message_id()) # continue to compose encrypt = mail.get_content_subtype() == 'encrypted' await ui.apply_command(ComposeCommand(envelope=envelope, spawn=self.force_spawn, encrypt=encrypt)) @registerCommand(MODE, 'forward', arguments=[ (['--attach'], {'action': 'store_true', 'help': 'attach original mail'}), (['--spawn'], {'action': cargparse.BooleanAction, 'default': None, 'help': 'open editor in new window'})]) class ForwardCommand(Command): """forward message""" repeatable = True def __init__(self, message=None, attach=True, spawn=None, **kwargs): """ :param message: message to forward (defaults to selected message) :type message: `alot.db.message.Message` :param attach: attach original mail instead of inline quoting its body :type attach: bool :param spawn: force spawning of editor in a new terminal :type spawn: bool """ self.message = message self.inline = not attach self.force_spawn = spawn Command.__init__(self, **kwargs) async def apply(self, ui): # get message to forward if not given in constructor if not self.message: self.message = ui.current_buffer.get_selected_message() mail = self.message.get_email() envelope = Envelope(passed=self.message) if self.inline: # inline mode # set body text name, address = self.message.get_author() timestamp = self.message.get_date() qf = settings.get_hook('forward_prefix') if qf: quote = qf(name, address, timestamp, message=mail, ui=ui, dbm=ui.dbman) else: quote = 'Forwarded message from %s (%s):\n' % ( name or address, timestamp) mailcontent = quote quotehook = settings.get_hook('text_quote') if quotehook: mailcontent += quotehook(self.message.get_body_text()) else: quote_prefix = settings.get('quote_prefix') for line in self.message.get_body_text().splitlines(): mailcontent += quote_prefix + line + '\n' envelope.body_txt = mailcontent for a in self.message.get_attachments(): envelope.attach(a) else: # attach original mode # attach original msg original_mail = Message() original_mail.set_type('message/rfc822') original_mail['Content-Disposition'] = 'attachment' original_mail.set_payload(mail.as_string(policy=email.policy.SMTP)) envelope.attach(Attachment(original_mail)) # copy subject subject = decode_header(mail.get('Subject', '')) subject = 'Fwd: ' + subject forward_subject_hook = settings.get_hook('forward_subject') if forward_subject_hook: subject = forward_subject_hook(subject) else: fsp = settings.get('forward_subject_prefix') if not subject.startswith(('Fwd:', fsp)): subject = fsp + subject envelope.add('Subject', subject) # Set forwarding reference headers envelope.add('References', '<%s>' % self.message.get_message_id()) envelope.add('X-Forwarded-Message-Id', '<%s>' % self.message.get_message_id()) # set From-header and sending account try: from_header, account = determine_sender(mail, 'reply') except AssertionError as e: ui.notify(str(e), priority='error') return envelope.add('From', from_header) envelope.account = account # continue to compose await ui.apply_command(ComposeCommand(envelope=envelope, spawn=self.force_spawn)) @registerCommand(MODE, 'bounce') class BounceMailCommand(Command): """directly re-send selected message""" repeatable = True def __init__(self, message=None, **kwargs): """ :param message: message to bounce (defaults to selected message) :type message: `alot.db.message.Message` """ self.message = message Command.__init__(self, **kwargs) async def apply(self, ui): # get mail to bounce if not self.message: self.message = ui.current_buffer.get_selected_message() mail = self.message.get_email() # look if this makes sense: do we have any accounts set up? my_accounts = settings.get_accounts() if not my_accounts: ui.notify('no accounts set', priority='error') return # remove "Resent-*" headers if already present del mail['Resent-From'] del mail['Resent-To'] del mail['Resent-Cc'] del mail['Resent-Date'] del mail['Resent-Message-ID'] # set Resent-From-header and sending account try: resent_from_header, account = determine_sender(mail, 'bounce') except AssertionError as e: ui.notify(str(e), priority='error') return mail['Resent-From'] = resent_from_header # set Reset-To allbooks = not settings.get('complete_matching_abook_only') logging.debug('allbooks: %s', allbooks) if account is not None: abooks = settings.get_addressbooks(order=[account], append_remaining=allbooks) logging.debug(abooks) completer = ContactsCompleter(abooks) else: completer = None to = await ui.prompt('To', completer=completer, history=ui.recipienthistory) if to is None: raise CommandCanceled() mail['Resent-To'] = to.strip(' \t\n,') logging.debug("bouncing mail") logging.debug(mail.__class__) await ui.apply_command(SendCommand(mail=mail)) @registerCommand(MODE, 'editnew', arguments=[ (['--spawn'], {'action': cargparse.BooleanAction, 'default': None, 'help': 'open editor in new window'})]) class EditNewCommand(Command): """edit message in as new""" def __init__(self, message=None, spawn=None, **kwargs): """ :param message: message to reply to (defaults to selected message) :type message: `alot.db.message.Message` :param spawn: force spawning of editor in a new terminal :type spawn: bool """ self.message = message self.force_spawn = spawn Command.__init__(self, **kwargs) async def apply(self, ui): if not self.message: self.message = ui.current_buffer.get_selected_message() mail = self.message.get_email() # copy most tags to the envelope tags = set(self.message.get_tags()) tags.difference_update({'inbox', 'sent', 'draft', 'killed', 'replied', 'signed', 'encrypted', 'unread', 'attachment'}) tags = list(tags) # set body text mailcontent = self.message.get_body_text() envelope = Envelope(bodytext=mailcontent, tags=tags) # copy selected headers to_copy = ['Subject', 'From', 'To', 'Cc', 'Bcc', 'In-Reply-To', 'References'] for key in to_copy: value = decode_header(mail.get(key, '')) if value: envelope.add(key, value) # copy attachments for b in self.message.get_attachments(): envelope.attach(b) await ui.apply_command(ComposeCommand(envelope=envelope, spawn=self.force_spawn, omit_signature=True)) @registerCommand( MODE, 'fold', help='fold message(s)', forced={'visible': False}, arguments=[(['query'], {'help': 'query used to filter messages to affect', 'nargs': '*'})]) @registerCommand( MODE, 'unfold', help='unfold message(s)', forced={'visible': True}, arguments=[(['query'], {'help': 'query used to filter messages to affect', 'nargs': '*'})]) @registerCommand( MODE, 'togglesource', help='display message source', forced={'raw': 'toggle'}, arguments=[(['query'], {'help': 'query used to filter messages to affect', 'nargs': '*'})]) @registerCommand( MODE, 'toggleheaders', help='display all headers', forced={'all_headers': 'toggle'}, arguments=[(['query'], {'help': 'query used to filter messages to affect', 'nargs': '*'})]) @registerCommand( MODE, 'indent', help='change message/reply indentation', arguments=[(['indent'], {'action': cargparse.ValidatedStoreAction, 'validator': cargparse.is_int_or_pm})]) @registerCommand( MODE, 'togglemimetree', help='disply mime tree of the message', forced={'mimetree': 'toggle'}, arguments=[(['query'], {'help': 'query used to filter messages to affect', 'nargs': '*'})]) @registerCommand( MODE, 'togglemimepart', help='switch between html and plain text message', forced={'mimepart': 'toggle'}, arguments=[(['query'], {'help': 'query used to filter messages to affect', 'nargs': '*'})]) class ChangeDisplaymodeCommand(Command): """fold or unfold messages""" repeatable = True def __init__(self, query=None, visible=None, raw=None, all_headers=None, indent=None, mimetree=None, mimepart=False, **kwargs): """ :param query: notmuch query string used to filter messages to affect :type query: str :param visible: unfold if `True`, fold if `False`, ignore if `None` :type visible: True, False, 'toggle' or None :param raw: display raw message text :type raw: True, False, 'toggle' or None :param all_headers: show all headers (only visible if not in raw mode) :type all_headers: True, False, 'toggle' or None :param indent: message/reply indentation :type indent: '+', '-', or int :param mimetree: show the mime tree of the message :type mimetree: True, False, 'toggle' or None """ self.query = None if query: self.query = ' '.join(query) self.visible = visible self.raw = raw self.all_headers = all_headers self.indent = indent self.mimetree = mimetree self.mimepart = mimepart Command.__init__(self, **kwargs) def _matches(self, msgt): if self.query is None or self.query == '*': return True msg = msgt.get_message() return msg.matches(self.query) def apply(self, ui): tbuffer = ui.current_buffer # set message/reply indentation if changed if self.indent is not None: if self.indent == '+': newindent = tbuffer._indent_width + 1 elif self.indent == '-': newindent = tbuffer._indent_width - 1 else: # argparse validation guarantees that self.indent # can be cast to an integer newindent = int(self.indent) # make sure indent remains non-negative tbuffer._indent_width = max(newindent, 0) tbuffer.rebuild() tbuffer.collapse_all() ui.update() logging.debug('matching lines %s...', self.query) if self.query is None: messagetrees = [tbuffer.get_selected_messagetree()] else: messagetrees = tbuffer.messagetrees() for mt in messagetrees: # determine new display values for this message if self.visible == 'toggle': visible = mt.is_collapsed(mt.root) else: visible = self.visible if not self._matches(mt): visible = not visible if self.raw == 'toggle': tbuffer.focus_selected_message() raw = not mt.display_source if self.raw == 'toggle' else self.raw all_headers = not mt.display_all_headers \ if self.all_headers == 'toggle' else self.all_headers if self.mimepart: if self.mimepart == 'toggle': message = mt.get_message() mimetype = {'plain': 'html', 'html': 'plain'}[ message.get_mime_part().get_content_subtype()] mimepart = get_body_part(message.get_email(), mimetype) elif self.mimepart is True: mimepart = ui.get_deep_focus().mimepart mt.set_mimepart(mimepart) ui.update() if self.mimetree == 'toggle': tbuffer.focus_selected_message() mimetree = not mt.display_mimetree \ if self.mimetree == 'toggle' else self.mimetree # collapse/expand depending on new 'visible' value if visible is False: mt.collapse(mt.root) elif visible is True: # could be None mt.expand(mt.root) tbuffer.focus_selected_message() # set new values in messagetree obj if raw is not None: mt.display_source = raw if all_headers is not None: mt.display_all_headers = all_headers if mimetree is not None: mt.display_mimetree = mimetree mt.debug() # let the messagetree reassemble itself mt.reassemble() # refresh the buffer (clears Tree caches etc) tbuffer.refresh() @registerCommand(MODE, 'pipeto', arguments=[ (['cmd'], {'help': 'shellcommand to pipe to', 'nargs': '+'}), (['--all'], {'action': 'store_true', 'help': 'pass all messages'}), (['--format'], {'help': 'output format', 'default': 'raw', 'choices': ['raw', 'decoded', 'id', 'filepath']}), (['--separately'], {'action': 'store_true', 'help': 'call command once for each message'}), (['--background'], {'action': 'store_true', 'help': 'don\'t stop the interface'}), (['--add_tags'], {'action': 'store_true', 'help': 'add \'Tags\' header to the message'}), (['--shell'], {'action': 'store_true', 'help': 'let the shell interpret the command'}), (['--notify_stdout'], {'action': 'store_true', 'help': 'display cmd\'s stdout as notification'}), ]) class PipeCommand(Command): """pipe message(s) to stdin of a shellcommand""" repeatable = True def __init__(self, cmd, all=False, separately=False, background=False, shell=False, notify_stdout=False, format='raw', add_tags=False, noop_msg='no command specified', confirm_msg='', done_msg=None, **kwargs): """ :param cmd: shellcommand to open :type cmd: str or list of str :param all: pipe all, not only selected message :type all: bool :param separately: call command once per message :type separately: bool :param background: do not suspend the interface :type background: bool :param shell: let the shell interpret the command :type shell: bool :param notify_stdout: display command\'s stdout as notification message :type notify_stdout: bool :param format: what to pipe to the processes stdin. one of: 'raw': message content as is, 'decoded': message content, decoded quoted printable, 'id': message ids, separated by newlines, 'filepath': paths to message files on disk :type format: str :param add_tags: add 'Tags' header to the message :type add_tags: bool :param noop_msg: error notification to show if `cmd` is empty :type noop_msg: str :param confirm_msg: confirmation question to ask (continues directly if unset) :type confirm_msg: str :param done_msg: notification message to show upon success :type done_msg: str """ Command.__init__(self, **kwargs) if isinstance(cmd, str): cmd = split_commandstring(cmd) self.cmd = cmd self.whole_thread = all self.separately = separately self.background = background self.shell = shell self.notify_stdout = notify_stdout self.output_format = format self.add_tags = add_tags self.noop_msg = noop_msg self.confirm_msg = confirm_msg self.done_msg = done_msg async def apply(self, ui): # abort if command unset if not self.cmd: ui.notify(self.noop_msg, priority='error') return # get messages to pipe if self.whole_thread: thread = ui.current_buffer.get_selected_thread() if not thread: return to_print = thread.get_messages().keys() else: to_print = [ui.current_buffer.get_selected_message()] # ask for confirmation if needed if self.confirm_msg: if (await ui.choice(self.confirm_msg, select='yes', cancel='no')) == 'no': return # prepare message sources pipestrings = [] separator = '\n\n' logging.debug('PIPETO format') logging.debug(self.output_format) if self.output_format == 'id': pipestrings = [e.get_message_id() for e in to_print] separator = '\n' elif self.output_format == 'filepath': pipestrings = [e.get_filename() for e in to_print] separator = '\n' else: for msg in to_print: mail = msg.get_email() if self.add_tags: mail.add_header('Tags', ', '.join(msg.get_tags())) if self.output_format == 'raw': pipestrings.append(mail.as_string()) elif self.output_format == 'decoded': headertext = extract_headers(mail) bodytext = msg.get_body_text() msgtext = '%s\n\n%s' % (headertext, bodytext) pipestrings.append(msgtext) if not self.separately: pipestrings = [separator.join(pipestrings)] if self.shell: self.cmd = [' '.join(self.cmd)] # do the monkey for mail in pipestrings: encoded_mail = mail.encode(urwid.util.detected_encoding) if self.background: logging.debug('call in background: %s', self.cmd) proc = subprocess.Popen(self.cmd, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = proc.communicate(encoded_mail) if self.notify_stdout: ui.notify(out) else: with ui.paused(): logging.debug('call: %s', self.cmd) # if proc.stdout is defined later calls to communicate # seem to be non-blocking! proc = subprocess.Popen(self.cmd, shell=True, stdin=subprocess.PIPE, # stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = proc.communicate(encoded_mail) if err: ui.notify(err, priority='error') return # display 'done' message if self.done_msg: ui.notify(self.done_msg) @registerCommand(MODE, 'remove', arguments=[ (['--all'], {'action': 'store_true', 'help': 'remove whole thread'})]) class RemoveCommand(Command): """remove message(s) from the index""" repeatable = True def __init__(self, all=False, **kwargs): """ :param all: remove all messages from thread, not just selected one :type all: bool """ Command.__init__(self, **kwargs) self.all = all async def apply(self, ui): threadbuffer = ui.current_buffer # get messages and notification strings if self.all: thread = threadbuffer.get_selected_thread() tid = thread.get_thread_id() messages = thread.get_messages().keys() confirm_msg = 'remove all messages in thread?' ok_msg = 'removed all messages in thread: %s' % tid else: msg = threadbuffer.get_selected_message() messages = [msg] confirm_msg = 'remove selected message?' ok_msg = 'removed message: %s' % msg.get_message_id() # ask for confirmation if (await ui.choice(confirm_msg, select='yes', cancel='no')) == 'no': return # notify callback def callback(): threadbuffer.rebuild() ui.notify(ok_msg) # remove messages for m in messages: ui.dbman.remove_message(m, afterwards=callback) await ui.apply_command(FlushCommand()) @registerCommand(MODE, 'print', arguments=[ (['--all'], {'action': 'store_true', 'help': 'print all messages'}), (['--raw'], {'action': 'store_true', 'help': 'pass raw mail string'}), (['--separately'], {'action': 'store_true', 'help': 'call print command once for each message'}), (['--add_tags'], {'action': 'store_true', 'help': 'add \'Tags\' header to the message'}), ]) class PrintCommand(PipeCommand): """print message(s)""" repeatable = True def __init__(self, all=False, separately=False, raw=False, add_tags=False, **kwargs): """ :param all: print all, not only selected messages :type all: bool :param separately: call print command once per message :type separately: bool :param raw: pipe raw message string to print command :type raw: bool :param add_tags: add 'Tags' header to the message :type add_tags: bool """ # get print command cmd = settings.get('print_cmd') or '' # set up notification strings if all: confirm_msg = 'print all messages in thread?' ok_msg = 'printed thread using %s' % cmd else: confirm_msg = 'print selected message?' ok_msg = 'printed message using %s' % cmd # no print cmd set noop_msg = 'no print command specified. Set "print_cmd" in the '\ 'global section.' PipeCommand.__init__(self, [cmd], all=all, separately=separately, background=True, shell=False, format='raw' if raw else 'decoded', add_tags=add_tags, noop_msg=noop_msg, confirm_msg=confirm_msg, done_msg=ok_msg, **kwargs) @registerCommand(MODE, 'save', arguments=[ (['--all'], {'action': 'store_true', 'help': 'save all attachments'}), (['path'], {'nargs': '?', 'help': 'path to save to'})]) class SaveAttachmentCommand(Command): """save attachment(s)""" def __init__(self, all=False, path=None, **kwargs): """ :param all: save all, not only selected attachment :type all: bool :param path: path to write to. if `all` is set, this must be a directory. :type path: str """ Command.__init__(self, **kwargs) self.all = all self.path = path async def apply(self, ui): pcomplete = PathCompleter() savedir = settings.get('attachment_prefix', '~') if self.all: msg = ui.current_buffer.get_selected_message() if not self.path: self.path = await ui.prompt('save attachments to', text=os.path.join(savedir, ''), completer=pcomplete) if self.path: if os.path.isdir(os.path.expanduser(self.path)): for a in msg.get_attachments(): dest = a.save(self.path) name = a.get_filename() if name: ui.notify('saved %s as: %s' % (name, dest)) else: ui.notify('saved attachment as: %s' % dest) else: ui.notify('not a directory: %s' % self.path, priority='error') else: raise CommandCanceled() else: # save focussed attachment focus = ui.get_deep_focus() if isinstance(focus, AttachmentWidget): attachment = focus.get_attachment() filename = attachment.get_filename() if not self.path: msg = 'save attachment (%s) to ' % filename initialtext = os.path.join(savedir, filename) self.path = await ui.prompt(msg, completer=pcomplete, text=initialtext) if self.path: try: dest = attachment.save(self.path) ui.notify('saved attachment as: %s' % dest) except (IOError, OSError) as e: ui.notify(str(e), priority='error') else: raise CommandCanceled() class OpenAttachmentCommand(Command): """displays an attachment according to mailcap""" def __init__(self, attachment, **kwargs): """ :param attachment: attachment to open :type attachment: :class:`~alot.db.attachment.Attachment` """ Command.__init__(self, **kwargs) self.attachment = attachment async def apply(self, ui): logging.info('open attachment') mimetype = self.attachment.get_content_type() # returns pair of preliminary command string and entry dict containing # more info. We only use the dict and construct the command ourselves _, entry = settings.mailcap_find_match(mimetype) if entry: afterwards = None # callback, will rm tempfile if used handler_stdin = None tempfile_name = None handler_raw_commandstring = entry['view'] # read parameter part = self.attachment.get_mime_representation() parms = tuple('='.join(p) for p in part.get_params()) # in case the mailcap defined command contains no '%s', # we pipe the files content to the handling command via stdin if '%s' in handler_raw_commandstring: nametemplate = entry.get('nametemplate', '%s') prefix, suffix = parse_mailcap_nametemplate(nametemplate) fn_hook = settings.get_hook('sanitize_attachment_filename') if fn_hook: # get filename filename = self.attachment.get_filename() prefix, suffix = fn_hook(filename, prefix, suffix) with tempfile.NamedTemporaryFile(delete=False, prefix=prefix, suffix=suffix) as tmpfile: tempfile_name = tmpfile.name self.attachment.write(tmpfile) def afterwards(): os.unlink(tempfile_name) else: handler_stdin = BytesIO() self.attachment.write(handler_stdin) # create handler command list handler_cmd = mailcap.subst(handler_raw_commandstring, mimetype, filename=tempfile_name, plist=parms) handler_cmdlist = split_commandstring(handler_cmd) # 'needsterminal' makes handler overtake the terminal # XXX: could this be repalced with "'needsterminal' not in entry"? overtakes = entry.get('needsterminal') is None await ui.apply_command(ExternalCommand(handler_cmdlist, stdin=handler_stdin, on_success=afterwards, thread=overtakes)) else: ui.notify('unknown mime type') @registerCommand( MODE, 'move', help='move focus in current buffer', arguments=[ (['movement'], {'nargs': argparse.REMAINDER, 'help': '''up, down, [half]page up, [half]page down, first, last, \ parent, first reply, last reply, \ next sibling, previous sibling, next, previous, \ next unfolded, previous unfolded, \ next NOTMUCH_QUERY, previous NOTMUCH_QUERY'''})]) class MoveFocusCommand(MoveCommand): def apply(self, ui): logging.debug(self.movement) original_focus = ui.get_deep_focus() tbuffer = ui.current_buffer if self.movement == 'parent': tbuffer.focus_parent() elif self.movement == 'first reply': tbuffer.focus_first_reply() elif self.movement == 'last reply': tbuffer.focus_last_reply() elif self.movement == 'next sibling': tbuffer.focus_next_sibling() elif self.movement == 'previous sibling': tbuffer.focus_prev_sibling() elif self.movement == 'next': tbuffer.focus_next() elif self.movement == 'previous': tbuffer.focus_prev() elif self.movement == 'next unfolded': tbuffer.focus_next_unfolded() elif self.movement == 'previous unfolded': tbuffer.focus_prev_unfolded() elif self.movement.startswith('next '): query = self.movement[5:].strip() tbuffer.focus_next_matching(query) elif self.movement.startswith('previous '): query = self.movement[9:].strip() tbuffer.focus_prev_matching(query) else: MoveCommand.apply(self, ui) # TODO add 'next matching' if threadbuffer stores the original query # TODO: add next by date.. if original_focus != ui.get_deep_focus(): ui.update() @registerCommand(MODE, 'select') class ThreadSelectCommand(Command): """select focussed element: - if it is a message summary, toggle visibility of the message; - if it is an attachment line, open the attachment - if it is a mimepart, toggle visibility of the mimepart""" async def apply(self, ui): focus = ui.get_deep_focus() if isinstance(focus, AttachmentWidget): logging.info('open attachment') await ui.apply_command(OpenAttachmentCommand(focus.get_attachment())) elif getattr(focus, 'mimepart', False): if isinstance(focus.mimepart, Attachment): await ui.apply_command(OpenAttachmentCommand(focus.mimepart)) else: await ui.apply_command(ChangeDisplaymodeCommand( mimepart=True, mimetree='toggle')) else: await ui.apply_command(ChangeDisplaymodeCommand(visible='toggle')) RetagPromptCommand = registerCommand(MODE, 'retagprompt')(RetagPromptCommand) @registerCommand( MODE, 'tag', forced={'action': 'add'}, arguments=[ (['--all'], {'action': 'store_true', 'help': 'tag all messages in thread'}), (['--no-flush'], {'action': 'store_false', 'dest': 'flush', 'help': 'postpone a writeout to the index'}), (['tags'], {'help': 'comma separated list of tags'})], help='add tags to message(s)', ) @registerCommand( MODE, 'retag', forced={'action': 'set'}, arguments=[ (['--all'], {'action': 'store_true', 'help': 'tag all messages in thread'}), (['--no-flush'], {'action': 'store_false', 'dest': 'flush', 'help': 'postpone a writeout to the index'}), (['tags'], {'help': 'comma separated list of tags'})], help='set message(s) tags.', ) @registerCommand( MODE, 'untag', forced={'action': 'remove'}, arguments=[ (['--all'], {'action': 'store_true', 'help': 'tag all messages in thread'}), (['--no-flush'], {'action': 'store_false', 'dest': 'flush', 'help': 'postpone a writeout to the index'}), (['tags'], {'help': 'comma separated list of tags'})], help='remove tags from message(s)', ) @registerCommand( MODE, 'toggletags', forced={'action': 'toggle'}, arguments=[ (['--all'], {'action': 'store_true', 'help': 'tag all messages in thread'}), (['--no-flush'], {'action': 'store_false', 'dest': 'flush', 'help': 'postpone a writeout to the index'}), (['tags'], {'help': 'comma separated list of tags'})], help='flip presence of tags on message(s)', ) class TagCommand(Command): """manipulate message tags""" repeatable = True def __init__(self, tags='', action='add', all=False, flush=True, **kwargs): """ :param tags: comma separated list of tagstrings to set :type tags: str :param action: adds tags if 'add', removes them if 'remove', adds tags and removes all other if 'set' or toggle individually if 'toggle' :type action: str :param all: tag all messages in thread :type all: bool :param flush: immediately write out to the index :type flush: bool """ self.tagsstring = tags self.all = all self.action = action self.flush = flush Command.__init__(self, **kwargs) async def apply(self, ui): tbuffer = ui.current_buffer if self.all: messagetrees = tbuffer.messagetrees() testquery = "thread:%s" % \ tbuffer.get_selected_thread().get_thread_id() else: messagetrees = [tbuffer.get_selected_messagetree()] testquery = "mid:%s" % tbuffer.get_selected_mid() def refresh(): for mt in messagetrees: mt.refresh() # put currently selected message id on a block list for the # auto-remove-unread feature. This makes sure that explicit # tag-unread commands for the current message are not undone on the # next keypress (triggering the autorm again)... mid = tbuffer.get_selected_mid() tbuffer._auto_unread_dont_touch_mids.add(mid) tbuffer.refresh() tags = [t for t in self.tagsstring.split(',') if t] try: if self.action == 'add': ui.dbman.tag(testquery, tags, remove_rest=False, afterwards=refresh) if self.action == 'set': ui.dbman.tag(testquery, tags, remove_rest=True, afterwards=refresh) elif self.action == 'remove': ui.dbman.untag(testquery, tags, afterwards=refresh) elif self.action == 'toggle': ui.dbman.toggle_tags(testquery, tags, afterwards=refresh) except DatabaseROError: ui.notify('index in read-only mode', priority='error') return # flush index if self.flush: await ui.apply_command(FlushCommand()) alot-0.11/alot/commands/utils.py000066400000000000000000000102761466311112200166470ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import re import logging from ..errors import GPGProblem, GPGCode from ..settings.const import settings from ..settings.errors import NoMatchingAccount from .. import crypto async def update_keys(ui, envelope, block_error=False, signed_only=False): """Find and set the encryption keys in an envolope. :param ui: the main user interface object :type ui: alot.ui.UI :param envolope: the envolope buffer object :type envolope: alot.buffers.EnvelopeBuffer :param block_error: wether error messages for the user should expire automatically or block the ui :type block_error: bool :param signed_only: only use keys whose uid is signed (trusted to belong to the key) :type signed_only: bool """ encrypt_keys = [] for header in ('To', 'Cc'): if header not in envelope.headers: continue for recipient in envelope.headers[header][0].split(','): if not recipient: continue match = re.search("<(.*@.*)>", recipient) if match: recipient = match.group(1) encrypt_keys.append(recipient) logging.debug("encryption keys: " + str(encrypt_keys)) keys = await _get_keys(ui, encrypt_keys, block_error=block_error, signed_only=signed_only) if keys: envelope.encrypt_keys = keys envelope.encrypt = True if 'From' in envelope.headers: try: if envelope.account is None: envelope.account = settings.account_matching_address( envelope['From']) acc = envelope.account if acc.encrypt_to_self: if acc.gpg_key: logging.debug('encrypt to self: %s', acc.gpg_key.fpr) envelope.encrypt_keys[acc.gpg_key.fpr] = acc.gpg_key else: logging.debug('encrypt to self: no gpg_key in account') except NoMatchingAccount: logging.debug('encrypt to self: no account found') else: envelope.encrypt = False async def _get_keys(ui, encrypt_keyids, block_error=False, signed_only=False): """Get several keys from the GPG keyring. The keys are selected by keyid and are checked if they can be used for encryption. :param ui: the main user interface object :type ui: alot.ui.UI :param encrypt_keyids: the key ids of the keys to get :type encrypt_keyids: list(str) :param block_error: wether error messages for the user should expire automatically or block the ui :type block_error: bool :param signed_only: only return keys whose uid is signed (trusted to belong to the key) :type signed_only: bool :returns: the available keys indexed by their OpenPGP fingerprint :rtype: dict(str->gpg key object) """ keys = {} for keyid in encrypt_keyids: try: key = crypto.get_key(keyid, validate=True, encrypt=True, signed_only=signed_only) except GPGProblem as e: if e.code == GPGCode.AMBIGUOUS_NAME: tmp_choices = ['{} ({})'.format(k.uids[0].uid, k.fpr) for k in crypto.list_keys(hint=keyid)] choices = {str(i): t for i, t in enumerate(tmp_choices, 1)} keys_to_return = {str(i): t for i, t in enumerate([k for k in crypto.list_keys(hint=keyid)], 1)} choosen_key = await ui.choice("ambiguous keyid! Which " + "key do you want to use?", choices=choices, choices_to_return=keys_to_return) if choosen_key: keys[choosen_key.fpr] = choosen_key continue else: ui.notify(str(e), priority='error', block=block_error) continue keys[key.fpr] = key return keys alot-0.11/alot/completion/000077500000000000000000000000001466311112200154775ustar00rootroot00000000000000alot-0.11/alot/completion/__init__.py000066400000000000000000000000001466311112200175760ustar00rootroot00000000000000alot-0.11/alot/completion/abooks.py000066400000000000000000000026701466311112200173340ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from .completer import Completer from ..addressbook import AddressbookError from ..db.utils import formataddr from ..errors import CompletionError class AbooksCompleter(Completer): """Complete a contact from given address books.""" def __init__(self, abooks, addressesonly=False): """ :param abooks: used to look up email addresses :type abooks: list of :class:`~alot.account.AddresBook` :param addressesonly: only insert address, not the realname of the contact :type addressesonly: bool """ self.abooks = abooks self.addressesonly = addressesonly def complete(self, original, pos): if not self.abooks: return [] prefix = original[:pos] res = [] for abook in self.abooks: try: res = res + abook.lookup(prefix) except AddressbookError as e: raise CompletionError(e) if self.addressesonly: returnlist = [(addr, len(addr)) for (name, addr) in res] else: returnlist = [] for name, addr in res: newtext = formataddr((name, addr)) returnlist.append((newtext, len(newtext))) return returnlist alot-0.11/alot/completion/accounts.py000066400000000000000000000012671466311112200176760ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from alot.settings.const import settings from alot.db.utils import formataddr from .stringlist import StringlistCompleter class AccountCompleter(StringlistCompleter): """Completes users' own mailaddresses.""" def __init__(self, **kwargs): accounts = settings.get_accounts() resultlist = [formataddr((a.realname, str(a.address))) for a in accounts] StringlistCompleter.__init__(self, resultlist, match_anywhere=True, **kwargs) alot-0.11/alot/completion/argparse.py000066400000000000000000000030321466311112200176530ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import argparse from .completer import Completer from ..utils import argparse as cargparse class ArgparseOptionCompleter(Completer): """completes option parameters for a given argparse.Parser""" def __init__(self, parser): """ :param parser: the option parser we look up parameter and choices from :type parser: `argparse.ArgumentParser` """ self.parser = parser self.actions = parser._optionals._actions def complete(self, original, pos): pref = original[:pos] res = [] for act in self.actions: if '=' in pref: optionstring = pref[:pref.rfind('=') + 1] # get choices if 'choices' in act.__dict__: # TODO: respect prefix choices = act.choices or [] res = res + [optionstring + a for a in choices] else: for optionstring in act.option_strings: if optionstring.startswith(pref): # append '=' for options that await a string value if isinstance(act, (argparse._StoreAction, cargparse.BooleanAction)): optionstring += '=' res.append(optionstring) return [(a, len(a)) for a in res] alot-0.11/alot/completion/command.py000066400000000000000000000253661466311112200175030ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import logging from alot import commands from alot.buffers import EnvelopeBuffer from alot.settings.const import settings from alot.utils.cached_property import cached_property from .completer import Completer from .commandname import CommandNameCompleter from .tag import TagCompleter from .query import QueryCompleter from .contacts import ContactsCompleter from .accounts import AccountCompleter from .path import PathCompleter from .stringlist import StringlistCompleter from .multipleselection import MultipleSelectionCompleter from .cryptokey import CryptoKeyCompleter from .argparse import ArgparseOptionCompleter class CommandCompleter(Completer): """completes one command consisting of command name and parameters""" def __init__(self, dbman, mode, currentbuffer=None): """ :param dbman: used to look up available tagstrings :type dbman: :class:`~alot.db.DBManager` :param mode: mode identifier :type mode: str :param currentbuffer: currently active buffer. If defined, this will be used to dynamically extract possible completion strings :type currentbuffer: :class:`~alot.buffers.Buffer` """ self.dbman = dbman self.mode = mode self.currentbuffer = currentbuffer self._commandnamecompleter = CommandNameCompleter(mode) @cached_property def _querycompleter(self): return QueryCompleter(self.dbman) @cached_property def _tagcompleter(self): return TagCompleter(self.dbman) @cached_property def _contactscompleter(self): abooks = settings.get_addressbooks() return ContactsCompleter(abooks) @cached_property def _pathcompleter(self): return PathCompleter() @cached_property def _accountscompleter(self): return AccountCompleter() @cached_property def _secretkeyscompleter(self): return CryptoKeyCompleter(private=True) @cached_property def _publickeyscompleter(self): return CryptoKeyCompleter(private=False) def complete(self, original, pos): # remember how many preceding space characters we see until the command # string starts. We'll continue to complete from there on and will add # these whitespaces again at the very end whitespaceoffset = len(original) - len(original.lstrip()) original = original[whitespaceoffset:] pos = pos - whitespaceoffset words = original.split(' ', 1) res = [] if pos <= len(words[0]): # we complete commands for cmd, cpos in self._commandnamecompleter.complete(original, pos): newtext = ('%s %s' % (cmd, ' '.join(words[1:]))) res.append((newtext, cpos + 1)) else: cmd, params = words localpos = pos - (len(cmd) + 1) parser = commands.lookup_parser(cmd, self.mode) if parser is not None: # set 'res' - the result set of matching completionstrings # depending on the current mode and command # detect if we are completing optional parameter arguments_until_now = params[:localpos].split(' ') all_optionals = True logging.debug(str(arguments_until_now)) for a in arguments_until_now: logging.debug(a) if a and not a.startswith('-'): all_optionals = False # complete optional parameter if # 1. all arguments prior to current position are optional # 2. the parameter starts with '-' or we are at its beginning if all_optionals: myarg = arguments_until_now[-1] start_myarg = params.rindex(myarg) beforeme = params[:start_myarg] # set up local stringlist completer # and let it complete for given list of options localcompleter = ArgparseOptionCompleter(parser) localres = localcompleter.complete(myarg, len(myarg)) res = [( beforeme + c, p + start_myarg) for (c, p) in localres] # global elif cmd == 'search': res = self._querycompleter.complete(params, localpos) elif cmd == 'help': res = self._commandnamecompleter.complete(params, localpos) elif cmd in ['compose']: res = self._contactscompleter.complete(params, localpos) # search elif self.mode == 'search' and cmd == 'refine': res = self._querycompleter.complete(params, localpos) elif self.mode == 'search' and cmd in ['tag', 'retag', 'untag', 'toggletags']: localcomp = MultipleSelectionCompleter(self._tagcompleter, separator=',') res = localcomp.complete(params, localpos) elif self.mode == 'search' and cmd == 'toggletag': localcomp = MultipleSelectionCompleter(self._tagcompleter, separator=' ') res = localcomp.complete(params, localpos) # envelope elif self.mode == 'envelope' and cmd == 'set': plist = params.split(' ', 1) if len(plist) == 1: # complete from header keys localprefix = params headers = ['Subject', 'To', 'Cc', 'Bcc', 'In-Reply-To', 'From'] localcompleter = StringlistCompleter(headers) localres = localcompleter.complete( localprefix, localpos) res = [(c, p + 6) for (c, p) in localres] else: # must have 2 elements header, params = plist localpos = localpos - (len(header) + 1) if header.lower() in ['to', 'cc', 'bcc']: res = self._contactscompleter.complete(params, localpos) elif header.lower() == 'from': res = self._accountscompleter.complete(params, localpos) # prepend 'set ' + header and correct position def f(completed, pos): return ('%s %s' % (header, completed), pos + len(header) + 1) res = [f(c, p) for c, p in res] logging.debug(res) elif self.mode == 'envelope' and cmd == 'unset': plist = params.split(' ', 1) if len(plist) == 1: # complete from header keys localprefix = params buf = self.currentbuffer if buf: if isinstance(buf, EnvelopeBuffer): available = buf.envelope.headers.keys() localcompleter = StringlistCompleter(available) localres = localcompleter.complete(localprefix, localpos) res = [(c, p + 6) for (c, p) in localres] elif self.mode == 'envelope' and cmd == 'attach': res = self._pathcompleter.complete(params, localpos) elif self.mode == 'envelope' and cmd in ['sign', 'togglesign']: res = self._secretkeyscompleter.complete(params, localpos) elif self.mode == 'envelope' and cmd in ['encrypt', 'rmencrypt', 'toggleencrypt']: res = self._publickeyscompleter.complete(params, localpos) elif self.mode == 'envelope' and cmd in ['tag', 'toggletags', 'untag', 'retag']: localcomp = MultipleSelectionCompleter(self._tagcompleter, separator=',') res = localcomp.complete(params, localpos) # thread elif self.mode == 'thread' and cmd == 'save': res = self._pathcompleter.complete(params, localpos) elif self.mode == 'thread' and cmd in ['fold', 'unfold', 'togglesource', 'toggleheaders', 'togglemimetree', 'togglemimepart']: res = self._querycompleter.complete(params, localpos) elif self.mode == 'thread' and cmd in ['tag', 'retag', 'untag', 'toggletags']: localcomp = MultipleSelectionCompleter(self._tagcompleter, separator=',') res = localcomp.complete(params, localpos) elif cmd == 'move': directions = ['up', 'down', 'page up', 'page down', 'halfpage up', 'halfpage down', 'first', 'last'] if self.mode == 'thread': directions += ['parent', 'first reply', 'last reply', 'next sibling', 'previous sibling', 'next', 'previous', 'next unfolded', 'previous unfolded'] localcompleter = StringlistCompleter(directions) res = localcompleter.complete(params, localpos) # prepend cmd and correct position res = [('%s %s' % (cmd, t), p + len(cmd) + 1) for (t, p) in res] # re-insert whitespaces and correct position wso = whitespaceoffset res = [(' ' * wso + cmdstr, p + wso) for cmdstr, p in res] return res alot-0.11/alot/completion/commandline.py000066400000000000000000000035521466311112200203440ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from .completer import Completer from .command import CommandCompleter from ..helper import split_commandline class CommandLineCompleter(Completer): """completes command lines: semicolon separated command strings""" def __init__(self, dbman, mode, currentbuffer=None): """ :param dbman: used to look up available tagstrings :type dbman: :class:`~alot.db.DBManager` :param mode: mode identifier :type mode: str :param currentbuffer: currently active buffer. If defined, this will be used to dynamically extract possible completion strings :type currentbuffer: :class:`~alot.buffers.Buffer` """ self._commandcompleter = CommandCompleter(dbman, mode, currentbuffer) @staticmethod def get_context(line, pos): """ computes start and end position of substring of line that is the command string under given position """ commands = split_commandline(line) + [''] i = 0 start = 0 end = len(commands[i]) while pos > end: i += 1 start = end + 1 end += 1 + len(commands[i]) return start, end def complete(self, original, pos): cstart, cend = self.get_context(original, pos) before = original[:cstart] after = original[cend:] cmdstring = original[cstart:cend] cpos = pos - cstart res = [] for ccmd, ccpos in self._commandcompleter.complete(cmdstring, cpos): newtext = before + ccmd + after newpos = pos + (ccpos - cpos) res.append((newtext, newpos)) return res alot-0.11/alot/completion/commandname.py000066400000000000000000000015151466311112200203320ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import logging from alot import commands from .completer import Completer class CommandNameCompleter(Completer): """Completes command names.""" def __init__(self, mode): """ :param mode: mode identifier :type mode: str """ self.mode = mode def complete(self, original, pos): commandprefix = original[:pos] logging.debug('original="%s" prefix="%s"', original, commandprefix) cmdlist = commands.COMMANDS['global'].copy() cmdlist.update(commands.COMMANDS[self.mode]) matching = [t for t in cmdlist if t.startswith(commandprefix)] return [(t, len(t)) for t in matching] alot-0.11/alot/completion/completer.py000066400000000000000000000022511466311112200200430ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import abc class Completer: """base class for completers""" __metaclass__ = abc.ABCMeta @abc.abstractmethod def complete(self, original, pos): """returns a list of completions and cursor positions for the string `original` from position `pos` on. :param original: the string to complete :type original: str :param pos: starting position to complete from :type pos: int :returns: pairs of completed string and cursor position in the new string :rtype: list of (str, int) :raises: :exc:`CompletionError` """ pass def relevant_part(self, original, pos): """ Calculate the subword in a ' '-separated list of substrings of `original` that `pos` is in. """ start = original.rfind(' ', 0, pos) + 1 end = original.find(' ', pos - 1) if end == -1: end = len(original) return original[start:end], start, end, pos - start alot-0.11/alot/completion/contacts.py000066400000000000000000000015001466311112200176630ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from .multipleselection import MultipleSelectionCompleter from .abooks import AbooksCompleter class ContactsCompleter(MultipleSelectionCompleter): """completes contacts from given address books""" def __init__(self, abooks, addressesonly=False): """ :param abooks: used to look up email addresses :type abooks: list of :class:`~alot.account.AddresBook` :param addressesonly: only insert address, not the realname of the contact :type addressesonly: bool """ self._completer = AbooksCompleter(abooks, addressesonly=addressesonly) self._separator = ', ' alot-0.11/alot/completion/cryptokey.py000066400000000000000000000014151466311112200201030ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from alot import crypto from .stringlist import StringlistCompleter class CryptoKeyCompleter(StringlistCompleter): """completion for gpg keys""" def __init__(self, private=False): """ :param private: return private keys :type private: bool """ keys = crypto.list_keys(private=private) resultlist = [] for k in keys: for s in k.subkeys: resultlist.append(s.keyid) for u in k.uids: resultlist.append(u.email) StringlistCompleter.__init__(self, resultlist, match_anywhere=True) alot-0.11/alot/completion/multipleselection.py000066400000000000000000000034531466311112200216170ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from .completer import Completer class MultipleSelectionCompleter(Completer): """ Meta-Completer that turns any Completer into one that deals with a list of completion strings using the wrapped Completer. This allows for example to easily construct a completer for comma separated recipient-lists using a :class:`ContactsCompleter`. """ def __init__(self, completer, separator=', '): """ :param completer: completer to use for individual substrings :type completer: Completer :param separator: separator used to split the completion string into substrings to be fed to `completer`. :type separator: str """ self._completer = completer self._separator = separator def relevant_part(self, original, pos): """Calculate the subword of `original` that `pos` is in.""" start = original.rfind(self._separator, 0, pos) if start == -1: start = 0 else: start = start + len(self._separator) end = original.find(self._separator, pos - 1) if end == -1: end = len(original) return original[start:end], start, end, pos - start def complete(self, original, pos): mypart, start, end, mypos = self.relevant_part(original, pos) res = [] for c, _ in self._completer.complete(mypart, mypos): newprefix = original[:start] + c if not original[end:].startswith(self._separator): newprefix += self._separator res.append((newprefix + original[end:], len(newprefix))) return res alot-0.11/alot/completion/namedquery.py000066400000000000000000000012241466311112200202220ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from .stringlist import StringlistCompleter class NamedQueryCompleter(StringlistCompleter): """Complete the name of a named query string.""" def __init__(self, dbman): """ :param dbman: used to look up named query strings in the DB :type dbman: :class:`~alot.db.DBManager` """ # mapping of alias to query string (dict str -> str) nqueries = dbman.get_named_queries() StringlistCompleter.__init__(self, list(nqueries)) alot-0.11/alot/completion/path.py000066400000000000000000000025301466311112200170050ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import glob import os from .completer import Completer class PathCompleter(Completer): """Completes for file system paths.""" def complete(self, original, pos): if not original: return [('~/', 2)] prefix = os.path.expanduser(original[:pos]) def escape(path): """Escape all backslashes and spaces in path with a backslash. :param path: the path to escape :type path: str :returns: the escaped path :rtype: str """ return path.replace('\\', '\\\\').replace(' ', r'\ ') def deescape(escaped_path): """Remove escaping backslashes in front of spaces and backslashes. :param escaped_path: a path potentially with escaped spaces and backslashs :type escaped_path: str :returns: the actual path :rtype: str """ return escaped_path.replace('\\ ', ' ').replace('\\\\', '\\') def prep(path): escaped_path = escape(path) return escaped_path, len(escaped_path) return [prep(g) for g in glob.glob(deescape(prefix) + '*')] alot-0.11/alot/completion/query.py000066400000000000000000000046631466311112200172270ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import re from alot.settings.const import settings from .completer import Completer from .abooks import AbooksCompleter from .tag import TagCompleter from .namedquery import NamedQueryCompleter class QueryCompleter(Completer): """completion for a notmuch query string""" def __init__(self, dbman): """ :param dbman: used to look up available tagstrings :type dbman: :class:`~alot.db.DBManager` """ self.dbman = dbman abooks = settings.get_addressbooks() self._abookscompleter = AbooksCompleter(abooks, addressesonly=True) self._tagcompleter = TagCompleter(dbman) self._nquerycompleter = NamedQueryCompleter(dbman) self.keywords = ['tag', 'from', 'to', 'subject', 'attachment', 'is', 'id', 'thread', 'folder', 'query'] def complete(self, original, pos): mypart, start, end, mypos = self.relevant_part(original, pos) myprefix = mypart[:mypos] m = re.search(r'(tag|is|to|from|query):(\w*)', myprefix) if m: cmd, _ = m.groups() cmdlen = len(cmd) + 1 # length of the keyword part including colon if cmd in ['to', 'from']: localres = self._abookscompleter.complete(mypart[cmdlen:], mypos - cmdlen) elif cmd in ['query']: localres = self._nquerycompleter.complete(mypart[cmdlen:], mypos - cmdlen) else: localres = self._tagcompleter.complete(mypart[cmdlen:], mypos - cmdlen) resultlist = [] for ltxt, lpos in localres: newtext = original[:start] + cmd + ':' + ltxt + original[end:] newpos = start + len(cmd) + 1 + lpos resultlist.append((newtext, newpos)) return resultlist else: matched = (t for t in self.keywords if t.startswith(myprefix)) resultlist = [] for keyword in matched: newprefix = original[:start] + keyword + ':' resultlist.append((newprefix + original[end:], len(newprefix))) return resultlist alot-0.11/alot/completion/stringlist.py000066400000000000000000000020701466311112200202520ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import re from .completer import Completer class StringlistCompleter(Completer): """Completer for a fixed list of strings.""" def __init__(self, resultlist, ignorecase=True, match_anywhere=False): """ :param resultlist: strings used for completion :type resultlist: list of str :param liberal: match case insensitive and not prefix-only :type liberal: bool """ self.resultlist = resultlist self.flags = re.IGNORECASE if ignorecase else 0 self.match_anywhere = match_anywhere def complete(self, original, pos): pref = original[:pos] re_prefix = '.*' if self.match_anywhere else '' def match(s, m): r = '{}{}.*'.format(re_prefix, re.escape(m)) return re.match(r, s, flags=self.flags) is not None return [(a, len(a)) for a in self.resultlist if match(a, pref)] alot-0.11/alot/completion/tag.py000066400000000000000000000010531466311112200166230ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from .stringlist import StringlistCompleter class TagCompleter(StringlistCompleter): """Complete a tagstring.""" def __init__(self, dbman): """ :param dbman: used to look up available tagstrings :type dbman: :class:`~alot.db.DBManager` """ resultlist = dbman.get_all_tags() StringlistCompleter.__init__(self, resultlist) alot-0.11/alot/completion/tags.py000066400000000000000000000011431466311112200170060ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from .multipleselection import MultipleSelectionCompleter from .tag import TagCompleter class TagsCompleter(MultipleSelectionCompleter): """Complete a comma separated list of tagstrings.""" def __init__(self, dbman): """ :param dbman: used to look up available tagstrings :type dbman: :class:`~alot.db.DBManager` """ self._completer = TagCompleter(dbman) self._separator = ',' alot-0.11/alot/crypto.py000066400000000000000000000307161466311112200152270ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # Copyright © 2017-2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import gpg from .errors import GPGProblem, GPGCode def RFC3156_micalg_from_algo(hash_algo): """ Converts a GPGME hash algorithm name to one conforming to RFC3156. GPGME returns hash algorithm names such as "SHA256", but RFC3156 says that programs need to use names such as "pgp-sha256" instead. :param str hash_algo: GPGME hash_algo :returns: the lowercase name of of the algorithm with "pgp-" prepended :rtype: str """ # hash_algo will be something like SHA256, but we need pgp-sha256. algo = gpg.core.hash_algo_name(hash_algo) if algo is None: raise GPGProblem('Unknown hash algorithm {}'.format(algo), code=GPGCode.INVALID_HASH_ALGORITHM) return 'pgp-' + algo.lower() def get_key(keyid, validate=False, encrypt=False, sign=False, signed_only=False): """ Gets a key from the keyring by filtering for the specified keyid, but only if the given keyid is specific enough (if it matches multiple keys, an exception will be thrown). If validate is True also make sure that returned key is not invalid, revoked or expired. In addition if encrypt or sign is True also validate that key is valid for that action. For example only keys with private key can sign. If signed_only is True make sure that the user id can be trusted to belong to the key (is signed). This last check will only work if the keyid is part of the user id associated with the key, not if it is part of the key fingerprint. :param keyid: filter term for the keyring (usually a key ID) :type keyid: str :param validate: validate that returned keyid is valid :type validate: bool :param encrypt: when validating confirm that returned key can encrypt :type encrypt: bool :param sign: when validating confirm that returned key can sign :type sign: bool :param signed_only: only return keys whose uid is signed (trusted to belong to the key) :type signed_only: bool :returns: A gpg key matching the given parameters :rtype: gpg.gpgme._gpgme_key :raises ~alot.errors.GPGProblem: if the keyid is ambiguous :raises ~alot.errors.GPGProblem: if there is no key that matches the parameters :raises ~alot.errors.GPGProblem: if a key is found, but signed_only is true and the key is unused """ ctx = gpg.core.Context() try: key = ctx.get_key(keyid) if validate: validate_key(key, encrypt=encrypt, sign=sign) except gpg.errors.KeyNotFound: raise GPGProblem('Cannot find key for "{}".'.format(keyid), code=GPGCode.NOT_FOUND) except gpg.errors.GPGMEError as e: if e.getcode() == gpg.errors.AMBIGUOUS_NAME: # When we get here it means there were multiple keys returned by # gpg for given keyid. Unfortunately gpgme returns invalid and # expired keys together with valid keys. If only one key is valid # for given operation maybe we can still return it instead of # raising exception valid_key = None for k in list_keys(hint=keyid): try: validate_key(k, encrypt=encrypt, sign=sign) except GPGProblem: # if the key is invalid for given action skip it continue if valid_key: # we have already found one valid key and now we find # another? We really received an ambiguous keyid raise GPGProblem( "More than one key found matching this filter. " "Please be more specific " "(use a key ID like 4AC8EE1D).", code=GPGCode.AMBIGUOUS_NAME) valid_key = k if not valid_key: # there were multiple keys found but none of them are valid for # given action (we don't have private key, they are expired # etc), or there was no key at all raise GPGProblem( 'Can not find usable key for "{}".'.format(keyid), code=GPGCode.NOT_FOUND) return valid_key elif e.getcode() == gpg.errors.INV_VALUE: raise GPGProblem( 'Can not find usable key for "{}".'.format(keyid), code=GPGCode.NOT_FOUND) else: raise e # pragma: nocover if signed_only and not check_uid_validity(key, keyid): raise GPGProblem( 'Cannot find a trusworthy key for "{}".'.format(keyid), code=GPGCode.NOT_FOUND) return key def list_keys(hint=None, private=False): """ Returns a generator of all keys containing the fingerprint, or all keys if hint is None. The generator may raise exceptions of :class:gpg.errors.GPGMEError, and it is the caller's responsibility to handle them. :param hint: Part of a fingerprint to usee to search :type hint: str or None :param private: Whether to return public keys or secret keys :type private: bool :returns: A generator that yields keys. :rtype: Generator[gpg.gpgme.gpgme_key_t, None, None] """ ctx = gpg.core.Context() return ctx.keylist(hint, private) def detached_signature_for(plaintext_str, keys): """ Signs the given plaintext string and returns the detached signature. A detached signature in GPG speak is a separate blob of data containing a signature for the specified plaintext. :param bytes plaintext_str: bytestring to sign :param keys: list of one or more key to sign with. :type keys: list[gpg.gpgme._gpgme_key] :returns: A list of signature and the signed blob of data :rtype: tuple[list[gpg.results.NewSignature], str] """ ctx = gpg.core.Context(armor=True) ctx.signers = keys (sigblob, sign_result) = ctx.sign(plaintext_str, mode=gpg.constants.SIG_MODE_DETACH) return sign_result.signatures, sigblob def encrypt(plaintext_str, keys): """Encrypt data and return the encrypted form. :param bytes plaintext_str: the mail to encrypt :param key: optionally, a list of keys to encrypt with :type key: list[gpg.gpgme.gpgme_key_t] or None :returns: encrypted mail :rtype: str """ assert keys, 'Must provide at least one key to encrypt with' ctx = gpg.core.Context(armor=True) out = ctx.encrypt(plaintext_str, recipients=keys, sign=False, always_trust=True)[0] return out NO_ERROR = None def bad_signatures_to_str(error): """ Convert a bad signature exception to a text message. This is a workaround for gpg not handling non-ascii data correctly. :param BadSignatures error: BadSignatures exception """ return ", ".join("{}: {}".format(s.fpr, "Bad signature for key(s)") for s in error.result.signatures if s.status != NO_ERROR) def verify_detached(message, signature): """Verifies whether the message is authentic by checking the signature. :param bytes message: The message to be verified, in canonical form. :param bytes signature: the OpenPGP signature to verify :returns: a list of signatures :rtype: list[gpg.results.Signature] :raises alot.errors.GPGProblem: if the verification fails """ ctx = gpg.core.Context() try: verify_results = ctx.verify(message, signature)[1] return verify_results.signatures except gpg.errors.BadSignatures as e: raise GPGProblem(bad_signatures_to_str(e), code=GPGCode.BAD_SIGNATURE) except gpg.errors.GPGMEError as e: raise GPGProblem(str(e), code=e.getcode()) def decrypt_verify(encrypted, session_keys=None): """Decrypts the given ciphertext string and returns both the signatures (if any) and the plaintext. :param bytes encrypted: the mail to decrypt :param list[str] session_keys: a list OpenPGP session keys :returns: the signatures and decrypted plaintext data :rtype: tuple[list[gpg.resuit.Signature], str] :raises alot.errors.GPGProblem: if the decryption fails """ if session_keys is not None: try: return _decrypt_verify_session_keys(encrypted, session_keys) except GPGProblem: pass ctx = gpg.core.Context() return _decrypt_verify_with_context(ctx, encrypted) def _decrypt_verify_session_keys(encrypted, session_keys): """Decrypts the given ciphertext string using the session_keys and returns both the signatures (if any) and the plaintext. :param bytes encrypted: the mail to decrypt :param list[str] session_keys: a list OpenPGP session keys :returns: the signatures and decrypted plaintext data :rtype: tuple[list[gpg.resuit.Signature], str] :raises alot.errors.GPGProblem: if the decryption fails """ for key in session_keys: ctx = gpg.core.Context() ctx.set_ctx_flag("override-session-key", key) try: return _decrypt_verify_with_context(ctx, encrypted) except GPGProblem: continue raise GPGProblem("No valid session key", code=GPGCode.NOT_FOUND) def _decrypt_verify_with_context(ctx, encrypted): """Decrypts the given ciphertext string using the gpg context and returns both the signatures (if any) and the plaintext. :param gpg.Context ctx: the gpg context :param bytes encrypted: the mail to decrypt :returns: the signatures and decrypted plaintext data :rtype: tuple[list[gpg.resuit.Signature], str] :raises alot.errors.GPGProblem: if the decryption fails """ try: (plaintext, _, verify_result) = ctx.decrypt( encrypted, verify=True) sigs = verify_result.signatures except gpg.errors.GPGMEError as e: raise GPGProblem(str(e), code=e.getcode()) except gpg.errors.BadSignatures as e: (plaintext, _, _) = ctx.decrypt(encrypted, verify=False) sigs = e.result.signatures return sigs, plaintext def validate_key(key, sign=False, encrypt=False): """Assert that a key is valide and optionally that it can be used for signing or encrypting. Raise GPGProblem otherwise. :param key: the GPG key to check :type key: gpg.gpgme._gpgme_key :param sign: whether the key should be able to sign :type sign: bool :param encrypt: whether the key should be able to encrypt :type encrypt: bool :raises ~alot.errors.GPGProblem: If the key is revoked, expired, or invalid :raises ~alot.errors.GPGProblem: If encrypt is true and the key cannot be used to encrypt :raises ~alot.errors.GPGProblem: If sign is true and th key cannot be used to encrypt """ if key.revoked: raise GPGProblem('The key "{}" is revoked.'.format(key.uids[0].uid), code=GPGCode.KEY_REVOKED) elif key.expired: raise GPGProblem('The key "{}" is expired.'.format(key.uids[0].uid), code=GPGCode.KEY_EXPIRED) elif key.invalid: raise GPGProblem('The key "{}" is invalid.'.format(key.uids[0].uid), code=GPGCode.KEY_INVALID) if encrypt and not key.can_encrypt: raise GPGProblem( 'The key "{}" cannot be used to encrypt'.format(key.uids[0].uid), code=GPGCode.KEY_CANNOT_ENCRYPT) if sign and not key.can_sign: raise GPGProblem( 'The key "{}" cannot be used to sign'.format(key.uids[0].uid), code=GPGCode.KEY_CANNOT_SIGN) def check_uid_validity(key, email): """Check that a the email belongs to the given key. Also check the trust level of this connection. Only if the trust level is high enough (>=4) the email is assumed to belong to the key. :param key: the GPG key to which the email should belong :type key: gpg.gpgme._gpgme_key :param email: the email address that should belong to the key :type email: str :returns: whether the key can be assumed to belong to the given email :rtype: bool """ def check(key_uid): return (email == key_uid.email and not key_uid.revoked and not key_uid.invalid and key_uid.validity >= gpg.constants.validity.FULL) return any(check(u) for u in key.uids) alot-0.11/alot/db/000077500000000000000000000000001466311112200137135ustar00rootroot00000000000000alot-0.11/alot/db/__init__.py000066400000000000000000000003461466311112200160270ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from .thread import Thread from .message import Message alot-0.11/alot/db/attachment.py000066400000000000000000000056701466311112200164250ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # Copyright © 2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import os import tempfile import email.charset as charset from copy import deepcopy from ..helper import string_decode, humanize_size, guess_mimetype from .utils import decode_header charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') class Attachment: """represents a mail attachment""" def __init__(self, emailpart): """ :param emailpart: a non-multipart email that is the attachment :type emailpart: :class:`email.message.Message` """ self.part = emailpart def __str__(self): desc = '%s:%s (%s)' % (self.get_content_type(), self.get_filename(), humanize_size(self.get_size())) return string_decode(desc) def get_filename(self): """ return name of attached file. If the content-disposition header contains no file name, this returns `None` """ fname = self.part.get_filename() if fname: extracted_name = decode_header(fname) if extracted_name: return os.path.basename(extracted_name) return None def get_content_type(self): """mime type of the attachment part""" ctype = self.part.get_content_type() # replace underspecified mime description by a better guess if ctype in ['octet/stream', 'application/octet-stream', 'application/octetstream']: ctype = guess_mimetype(self.get_data()) return ctype def get_size(self): """returns attachments size in bytes""" return len(self.part.get_payload()) def save(self, path): """ save the attachment to disk. Uses :meth:`~get_filename` in case path is a directory """ filename = self.get_filename() path = os.path.expanduser(path) if os.path.isdir(path): if filename: basename = os.path.basename(filename) file_ = open(os.path.join(path, basename), "wb") else: file_ = tempfile.NamedTemporaryFile(delete=False, dir=path) else: file_ = open(path, "wb") # this throws IOErrors for invalid path self.write(file_) file_.close() return file_.name def write(self, fhandle): """writes content to a given filehandle""" fhandle.write(self.get_data()) def get_data(self): """return data blob from wrapped file""" return self.part.get_payload(decode=True) def get_mime_representation(self): """returns mime part that constitutes this attachment""" part = deepcopy(self.part) part.set_param('maxlinelen', '78', header='Content-Disposition') return part alot-0.11/alot/db/envelope.py000066400000000000000000000335631466311112200161140ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import glob import logging import os import re import email import email.policy from email.encoders import encode_7or8bit from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.mime.application import MIMEApplication import email.charset as charset import gpg from .attachment import Attachment from .. import __version__ from .. import helper from .. import crypto from ..settings.const import settings from ..errors import GPGProblem, GPGCode charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') class Envelope: """ a message that is not yet sent and still editable. It holds references to unencoded! body text and mail headers among other things. Envelope implements the python container API for easy access of header values. So `e['To']`, `e['To'] = 'foo@bar.baz'` and 'e.get_all('To')' would work for an envelope `e`.. """ headers = None """ dict containing the mail headers (a list of strings for each header key) """ body_txt = None """mail body (plaintext) as unicode string""" body_html = None """mail body (html) as unicode string""" tmpfile = None """template text for initial content""" attachments = None """list of :class:`Attachments `""" tags = [] """tags to add after successful sendout""" account = None """account to send from""" def __init__( self, template=None, bodytext=None, headers=None, attachments=None, sign=False, sign_key=None, encrypt=False, tags=None, replied=None, passed=None, account=None): """ :param template: if not None, the envelope will be initialised by :meth:`parsing ` this string before setting any other values given to this constructor. :type template: str :param bodytext: text used as body part :type bodytext: str :param headers: unencoded header values :type headers: dict (str -> [unicode]) :param attachments: file attachments to include :type attachments: list of :class:`~alot.db.attachment.Attachment` :param tags: tags to add after successful sendout and saving this msg :type tags: list of str :param replied: message being replied to :type replied: :class:`~alot.db.message.Message` :param passed: message being passed on :type replied: :class:`~alot.db.message.Message` :param account: account to send from :type account: :class:`Account` """ logging.debug('TEMPLATE: %s', template) if template: self.parse_template(template) logging.debug('PARSED TEMPLATE: %s', template) logging.debug('BODY: %s', self.body_txt) self.body_txt = bodytext or '' # TODO: if this was as collections.defaultdict a number of methods # could be simplified. self.headers = headers or {} self.attachments = list(attachments) if attachments is not None else [] self.sign = sign self.sign_key = sign_key self.encrypt = encrypt self.encrypt_keys = {} self.tags = tags or [] # tags to add after successful sendout self.replied = replied # message being replied to self.passed = passed # message being passed on self.sent_time = None self.modified_since_sent = False self.sending = False # semaphore to avoid accidental double sendout self.account = account def __str__(self): return "Envelope (%s)\n%s" % (self.headers, self.body_txt) def __setitem__(self, name, val): """setter for header values. This allows adding header like so: envelope['Subject'] = 'sm\xf8rebr\xf8d' """ if name not in self.headers: self.headers[name] = [] self.headers[name].append(val) if self.sent_time: self.modified_since_sent = True def __getitem__(self, name): """getter for header values. :raises: KeyError if undefined """ return self.headers[name][0] def __delitem__(self, name): del self.headers[name] if self.sent_time: self.modified_since_sent = True def __contains__(self, name): return name in self.headers def get(self, key, fallback=None): """secure getter for header values that allows specifying a `fallback` return string (defaults to None). This returns the first matching value and doesn't raise KeyErrors""" if key in self.headers: value = self.headers[key][0] else: value = fallback return value def get_all(self, key, fallback=None): """returns all header values for given key""" if key in self.headers: value = self.headers[key] else: value = fallback or [] return value def add(self, key, value): """add header value""" if key not in self.headers: self.headers[key] = [] self.headers[key].append(value) if self.sent_time: self.modified_since_sent = True def attach(self, attachment, filename=None, ctype=None): """ attach a file :param attachment: File to attach, given as :class:`~alot.db.attachment.Attachment` object or path to a file. :type attachment: :class:`~alot.db.attachment.Attachment` or str :param filename: filename to use in content-disposition. Will be ignored if `path` matches multiple files :param ctype: force content-type to be used for this attachment :type ctype: str """ if isinstance(attachment, Attachment): self.attachments.append(attachment) elif isinstance(attachment, str): path = os.path.expanduser(attachment) part = helper.mimewrap(path, filename, ctype) self.attachments.append(Attachment(part)) else: raise TypeError('attach accepts an Attachment or str') if self.sent_time: self.modified_since_sent = True def construct_mail(self): """ Compiles the information contained in this envelope into a :class:`email.Message`. """ # Build body text part. To properly sign/encrypt messages later on, we # convert the text to its canonical format (as per RFC 2015). canonical_format = self.body_txt.encode('utf-8') textpart = MIMEText(canonical_format, 'plain', 'utf-8') inner_msg = textpart if self.body_html: htmlpart = MIMEText(self.body_html, 'html', 'utf-8') inner_msg = MIMEMultipart('alternative') inner_msg.attach(textpart) inner_msg.attach(htmlpart) # wrap everything in a multipart container if there are attachments if self.attachments: msg = MIMEMultipart('mixed') msg.attach(inner_msg) # add attachments for a in self.attachments: msg.attach(a.get_mime_representation()) inner_msg = msg if self.sign: plaintext = inner_msg.as_bytes(policy=email.policy.SMTP) logging.debug('signing plaintext: %s', plaintext) try: signatures, signature_str = crypto.detached_signature_for( plaintext, [self.sign_key]) if len(signatures) != 1: raise GPGProblem("Could not sign message (GPGME " "did not return a signature)", code=GPGCode.KEY_CANNOT_SIGN) except gpg.errors.GPGMEError as e: if e.getcode() == gpg.errors.BAD_PASSPHRASE: # If GPG_AGENT_INFO is unset or empty, the user just does # not have gpg-agent running (properly). if os.environ.get('GPG_AGENT_INFO', '').strip() == '': msg = "Got invalid passphrase and GPG_AGENT_INFO\ not set. Please set up gpg-agent." raise GPGProblem(msg, code=GPGCode.BAD_PASSPHRASE) else: raise GPGProblem("Bad passphrase. Is gpg-agent " "running?", code=GPGCode.BAD_PASSPHRASE) raise GPGProblem(str(e), code=GPGCode.KEY_CANNOT_SIGN) micalg = crypto.RFC3156_micalg_from_algo(signatures[0].hash_algo) unencrypted_msg = MIMEMultipart( 'signed', micalg=micalg, protocol='application/pgp-signature') # wrap signature in MIMEcontainter stype = 'pgp-signature; name="signature.asc"' signature_mime = MIMEApplication( _data=signature_str.decode('ascii'), _subtype=stype, _encoder=encode_7or8bit) signature_mime['Content-Description'] = 'signature' signature_mime.set_charset('us-ascii') # add signed message and signature to outer message unencrypted_msg.attach(inner_msg) unencrypted_msg.attach(signature_mime) unencrypted_msg['Content-Disposition'] = 'inline' else: unencrypted_msg = inner_msg if self.encrypt: plaintext = unencrypted_msg.as_bytes(policy=email.policy.SMTP) logging.debug('encrypting plaintext: %s', plaintext) try: encrypted_str = crypto.encrypt( plaintext, list(self.encrypt_keys.values())) except gpg.errors.GPGMEError as e: raise GPGProblem(str(e), code=GPGCode.KEY_CANNOT_ENCRYPT) outer_msg = MIMEMultipart('encrypted', protocol='application/pgp-encrypted') version_str = 'Version: 1' encryption_mime = MIMEApplication(_data=version_str, _subtype='pgp-encrypted', _encoder=encode_7or8bit) encryption_mime.set_charset('us-ascii') encrypted_mime = MIMEApplication( _data=encrypted_str.decode('ascii'), _subtype='octet-stream', _encoder=encode_7or8bit) encrypted_mime.set_charset('us-ascii') outer_msg.attach(encryption_mime) outer_msg.attach(encrypted_mime) else: outer_msg = unencrypted_msg headers = self.headers.copy() # add Date header if 'Date' not in headers: headers['Date'] = [email.utils.formatdate(localtime=True)] # add Message-ID if 'Message-ID' not in headers: domain = self.account.message_id_domain headers['Message-ID'] = [email.utils.make_msgid(domain=domain)] if 'User-Agent' in headers: uastring_format = headers['User-Agent'][0] else: uastring_format = settings.get('user_agent').strip() uastring = uastring_format.format(version=__version__) if uastring: headers['User-Agent'] = [uastring] # set policy on outer_msg to ease encoding headers outer_msg.policy = email.policy.default # copy headers from envelope to mail for k, vlist in headers.items(): for v in vlist: outer_msg.add_header(k, v) return outer_msg def parse_template(self, raw, reset=False, only_body=False, target_body='plaintext'): """Parse a template or user edited string to fills this envelope. :param raw: the string to parse. :type raw: str :param reset: remove previous envelope content :type reset: bool :param only_body: do not parse headers :type only_body: bool :param target_body: body text alternative this should be stored in; can be 'plaintext' or 'html' :type reset: str """ logging.debug('GoT: """\n%s\n"""', raw) if self.sent_time: self.modified_since_sent = True if reset: self.headers = {} headerEndPos = 0 if not only_body: # go through multiline, utf-8 encoded headers # locally, lines are separated by a simple LF, not CRLF # we decode the edited text ourselves here as # email.message_from_file can't deal with raw utf8 header values headerRe = re.compile(r'^(?P.+?):(?P(.|\n[ \t\r\f\v])+)$', re.MULTILINE) for header in headerRe.finditer(raw): if header.start() > headerEndPos + 1: break # switched to body key = header.group('k') # simple unfolding as decribed in # https://tools.ietf.org/html/rfc2822#section-2.2.3 unfoldedValue = header.group('v').replace('\n', '') self.add(key, unfoldedValue.strip()) headerEndPos = header.end() # interpret 'Attach' pseudo header if 'Attach' in self: to_attach = [] for line in self.get_all('Attach'): gpath = os.path.expanduser(line.strip()) to_attach += [g for g in glob.glob(gpath) if os.path.isfile(g)] logging.debug('Attaching: %s', to_attach) for path in to_attach: self.attach(path) del self['Attach'] body = raw[headerEndPos:].strip() if target_body == 'html': self.body_html = body else: self.body_txt = body alot-0.11/alot/db/errors.py000066400000000000000000000010201466311112200155720ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file class DatabaseError(Exception): pass class DatabaseROError(DatabaseError): """cannot write to read-only database""" pass class DatabaseLockedError(DatabaseError): """cannot write to locked index""" pass class NonexistantObjectError(DatabaseError): """requested thread or message does not exist in the index""" pass alot-0.11/alot/db/manager.py000066400000000000000000000411061466311112200157010ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # Copyright © Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from collections import deque import contextlib import logging from notmuch2 import Database, NotmuchError, XapianError import notmuch2 from .errors import DatabaseError from .errors import DatabaseLockedError from .errors import DatabaseROError from .errors import NonexistantObjectError from .message import Message from .thread import Thread from .utils import is_subdir_of from ..settings.const import settings class DBManager: """ Keeps track of your index parameters, maintains a write-queue and lets you look up threads and messages directly to the persistent wrapper classes. """ _sort_orders = { 'oldest_first': notmuch2.Database.SORT.OLDEST_FIRST, 'newest_first': notmuch2.Database.SORT.NEWEST_FIRST, 'unsorted': notmuch2.Database.SORT.UNSORTED, 'message_id': notmuch2.Database.SORT.MESSAGE_ID, } """constants representing sort orders""" def __init__(self, path=None, ro=False, config=None): """ :param path: absolute path to the notmuch index :type path: str :param ro: open the index in read-only mode :type ro: bool :param config: absolute path to the notmuch config file :type path: str """ self.ro = ro self.path = path self.config = config self.writequeue = deque([]) self.processes = [] @property def exclude_tags(self): exclude_tags = settings.get('exclude_tags') if exclude_tags is not None: return exclude_tags return settings.get_notmuch_setting('search', 'exclude_tags') def flush(self): """ write out all queued write-commands in order, each one in a separate :meth:`atomic ` transaction. If this fails the current action is rolled back, stays in the write queue and an exception is raised. You are responsible to retry flushing at a later time if you want to ensure that the cached changes are applied to the database. :exception: :exc:`~errors.DatabaseROError` if db is opened read-only :exception: :exc:`~errors.DatabaseLockedError` if db is locked """ if self.ro: raise DatabaseROError() if self.writequeue: # read notmuch's config regarding imap flag synchronization sync = settings.get_notmuch_setting('maildir', 'synchronize_flags') # go through writequeue entries while self.writequeue: current_item = self.writequeue.popleft() logging.debug('write-out item: %s', str(current_item)) # watch out for notmuch errors to re-insert current_item # to the queue on errors try: # the first two coordinates are cnmdname and post-callback cmd, afterwards = current_item[:2] logging.debug('cmd created') # acquire a writeable db handler try: mode = Database.MODE.READ_WRITE db = Database(path=self.path, mode=mode, config=self.config) except NotmuchError: raise DatabaseLockedError() logging.debug('got write lock') # make this a transaction with db.atomic(): logging.debug('got atomic') if cmd == 'add': logging.debug('add') path, tags = current_item[2:] msg, _ = db.add(path, sync_flags=sync) logging.debug('added msg') with msg.frozen(): logging.debug('freeze') for tag in tags: msg.tags.add(tag) if sync: msg.tags.to_maildir_flags() logging.debug('added tags ') logging.debug('thaw') elif cmd == 'remove': path = current_item[2] db.remove(path) elif cmd == 'setconfig': key = current_item[2] value = current_item[3] db.config[key] = value else: # tag/set/untag querystring, tags = current_item[2:] if cmd == 'toggle': presenttags = self.collect_tags(querystring) to_remove = [] to_add = [] for tag in tags: if tag in presenttags: to_remove.append(tag) else: to_add.append(tag) for msg in db.messages(querystring): with msg.frozen(): if cmd == 'toggle': for tag in to_remove: msg.tags.discard(tag) for tag in to_add: msg.tags.add(tag) else: if cmd == 'set': msg.tags.clear() for tag in tags: if cmd == 'tag' or cmd == 'set': msg.tags.add(tag) elif cmd == 'untag': msg.tags.discard(tag) if sync: msg.tags.to_maildir_flags() logging.debug('ended atomic') # close db db.close() logging.debug('closed db') # call post-callback if callable(afterwards): logging.debug(str(afterwards)) afterwards() logging.debug('called callback') # re-insert item to the queue upon Xapian/NotmuchErrors except (XapianError, NotmuchError) as e: logging.exception(e) self.writequeue.appendleft(current_item) raise DatabaseError(str(e)) except DatabaseLockedError as e: logging.debug('index temporarily locked') self.writequeue.appendleft(current_item) raise e logging.debug('flush finished') def tag(self, querystring, tags, afterwards=None, remove_rest=False): """ add tags to messages matching `querystring`. This appends a tag operation to the write queue and raises :exc:`~errors.DatabaseROError` if in read only mode. :param querystring: notmuch search string :type querystring: str :param tags: a list of tags to be added :type tags: list of str :param afterwards: callback that gets called after successful application of this tagging operation :type afterwards: callable :param remove_rest: remove tags from matching messages before tagging :type remove_rest: bool :exception: :exc:`~errors.DatabaseROError` .. note:: This only adds the requested operation to the write queue. You need to call :meth:`DBManager.flush` to actually write out. """ if self.ro: raise DatabaseROError() if remove_rest: self.writequeue.append(('set', afterwards, querystring, tags)) else: self.writequeue.append(('tag', afterwards, querystring, tags)) def untag(self, querystring, tags, afterwards=None): """ removes tags from messages that match `querystring`. This appends an untag operation to the write queue and raises :exc:`~errors.DatabaseROError` if in read only mode. :param querystring: notmuch search string :type querystring: str :param tags: a list of tags to be added :type tags: list of str :param afterwards: callback that gets called after successful application of this tagging operation :type afterwards: callable :exception: :exc:`~errors.DatabaseROError` .. note:: This only adds the requested operation to the write queue. You need to call :meth:`DBManager.flush` to actually write out. """ if self.ro: raise DatabaseROError() self.writequeue.append(('untag', afterwards, querystring, tags)) def toggle_tags(self, querystring, tags, afterwards=None): """ toggles tags from messages that match `querystring`. This appends a toggle operation to the write queue and raises :exc:`~errors.DatabaseROError` if in read only mode. :param querystring: notmuch search string :type querystring: str :param tags: a list of tags to be added :type tags: list of str :param afterwards: callback that gets called after successful application of this tagging operation :type afterwards: callable :exception: :exc:`~errors.DatabaseROError` .. note:: This only adds the requested operation to the write queue. You need to call :meth:`DBManager.flush` to actually write out. """ if self.ro: raise DatabaseROError() self.writequeue.append(('toggle', afterwards, querystring, tags)) def count_messages(self, querystring): """returns number of messages that match `querystring`""" db = Database(path=self.path, mode=Database.MODE.READ_ONLY, config=self.config) return db.count_messages(querystring, exclude_tags=self.exclude_tags) def collect_tags(self, querystring): """returns tags of messages that match `querystring`""" db = Database(path=self.path, mode=Database.MODE.READ_ONLY, config=self.config) tagset = notmuch2._tags.ImmutableTagSet( db.messages(querystring, exclude_tags=self.exclude_tags), '_iter_p', notmuch2.capi.lib.notmuch_messages_collect_tags) return [t for t in tagset] def count_threads(self, querystring): """returns number of threads that match `querystring`""" db = Database(path=self.path, mode=Database.MODE.READ_ONLY, config=self.config) return db.count_threads(querystring, exclude_tags=self.exclude_tags) @contextlib.contextmanager def _with_notmuch_thread(self, tid): """returns :class:`notmuch2.Thread` with given id""" with Database(path=self.path, mode=Database.MODE.READ_ONLY, config=self.config) as db: try: yield next(db.threads('thread:' + tid)) except NotmuchError: errmsg = 'no thread with id %s exists!' % tid raise NonexistantObjectError(errmsg) def get_thread(self, tid): """returns :class:`Thread` with given thread id (str)""" with self._with_notmuch_thread(tid) as thread: return Thread(self, thread) @contextlib.contextmanager def _with_notmuch_message(self, mid): """returns :class:`notmuch2.Message` with given id""" with Database(path=self.path, mode=Database.MODE.READ_ONLY, config=self.config) as db: try: yield db.find_message(mid) except: errmsg = 'no message with id %s exists!' % mid raise NonexistantObjectError(errmsg) def get_message(self, mid): """returns :class:`Message` with given message id (str)""" with self._with_notmuch_message(mid) as msg: return Message(self, msg) def get_all_tags(self): """ returns all tagsstrings used in the database :rtype: list of str """ db = Database(path=self.path, mode=Database.MODE.READ_ONLY, config=self.config) return [t for t in db.tags] def get_named_queries(self): """ returns the named queries stored in the database. :rtype: dict (str -> str) mapping alias to full query string """ db = Database(path=self.path, mode=Database.MODE.READ_ONLY, config=self.config) return {k[6:]: db.config[k] for k in db.config if k.startswith('query.')} def get_threads(self, querystring, sort='newest_first'): """ asynchronously look up thread ids matching `querystring`. :param querystring: The query string to use for the lookup :type querystring: str. :param sort: Sort order. one of ['oldest_first', 'newest_first', 'message_id', 'unsorted'] :type query: str :returns: a pipe together with the process that asynchronously writes to it. :rtype: (:class:`multiprocessing.Pipe`, :class:`multiprocessing.Process`) """ assert sort in self._sort_orders db = Database(path=self.path, mode=Database.MODE.READ_ONLY, config=self.config) thread_ids = [t.threadid for t in db.threads(querystring, sort=self._sort_orders[sort], exclude_tags=self.exclude_tags)] for t in thread_ids: yield t def add_message(self, path, tags=None, afterwards=None): """ Adds a file to the notmuch index. :param path: path to the file :type path: str :param tags: tagstrings to add :type tags: list of str :param afterwards: callback to trigger after adding :type afterwards: callable or None """ tags = tags or [] if self.ro: raise DatabaseROError() if not is_subdir_of(path, self.path): msg = 'message path %s ' % path msg += ' is not below notmuchs ' msg += 'root path (%s)' % self.path raise DatabaseError(msg) else: self.writequeue.append(('add', afterwards, path, tags)) def remove_message(self, message, afterwards=None): """ Remove a message from the notmuch index :param message: message to remove :type message: :class:`Message` :param afterwards: callback to trigger after removing :type afterwards: callable or None """ if self.ro: raise DatabaseROError() path = message.get_filename() self.writequeue.append(('remove', afterwards, path)) def save_named_query(self, alias, querystring, afterwards=None): """ add an alias for a query string. These are stored in the notmuch database and can be used as part of more complex queries using the syntax "query:alias". See :manpage:`notmuch-search-terms(7)` for more info. :param alias: name of shortcut :type alias: str :param querystring: value, i.e., the full query string :type querystring: str :param afterwards: callback to trigger after adding the alias :type afterwards: callable or None """ if self.ro: raise DatabaseROError() self.writequeue.append(('setconfig', afterwards, 'query.' + alias, querystring)) def remove_named_query(self, alias, afterwards=None): """ remove a named query from the notmuch database. :param alias: name of shortcut :type alias: str :param afterwards: callback to trigger after adding the alias :type afterwards: callable or None """ if self.ro: raise DatabaseROError() self.writequeue.append(('setconfig', afterwards, 'query.' + alias, '')) alot-0.11/alot/db/message.py000066400000000000000000000254641466311112200157240ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import email import email.charset as charset import email.policy import functools from datetime import datetime from notmuch2 import NullPointerError from . import utils from .utils import get_body_part, extract_body_part from .utils import decode_header from .attachment import Attachment from .. import helper from ..settings.const import settings charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') @functools.total_ordering class Message: """ a persistent notmuch message object. It it uses a :class:`~alot.db.DBManager` for cached manipulation and lazy lookups. """ def __init__(self, dbman, msg, thread=None): """ :param dbman: db manager that is used for further lookups :type dbman: alot.db.DBManager :param msg: the wrapped message :type msg: notmuch2.Message :param thread: this messages thread (will be looked up later if `None`) :type thread: :class:`~alot.db.Thread` or `None` """ self._dbman = dbman self._id = msg.messageid self._thread_id = msg.threadid self._thread = thread try: self._datetime = datetime.fromtimestamp(msg.date) except ValueError: self._datetime = None self._filename = str(msg.path) self._email = None # will be read upon first use self._attachments = None # will be read upon first use self._mime_part = None # will be read upon first use self._mime_tree = None # will be read upon first use self._tags = msg.tags self._session_keys = [ value for _, value in msg.properties.getall(prefix="session-key", exact=True) ] try: sender = decode_header(msg.header('From')) if not sender: sender = decode_header(msg.header('Sender')) except (NullPointerError, LookupError): sender = None if sender: self._from = sender elif 'draft' in self._tags: acc = settings.get_accounts()[0] self._from = '"{}" <{}>'.format(acc.realname, str(acc.address)) else: self._from = '"Unknown" <>' def __str__(self): """prettyprint the message""" aname, aaddress = self.get_author() if not aname: aname = aaddress return "%s (%s)" % (aname, self.get_datestring()) def __hash__(self): """needed for sets of Messages""" return hash(self._id) def __eq__(self, other): if isinstance(other, type(self)): return self._id == other.get_message_id() return NotImplemented def __ne__(self, other): if isinstance(other, type(self)): return self._id != other.get_message_id() return NotImplemented def __lt__(self, other): if isinstance(other, type(self)): return self._id < other.get_message_id() return NotImplemented def get_email(self): """returns :class:`email.email.EmailMessage` for this message""" path = self.get_filename() warning = "Subject: Caution!\n"\ "Message file is no longer accessible:\n%s" % path if not self._email: try: with open(path, 'rb') as f: self._email = utils.decrypted_message_from_bytes( f.read(), self._session_keys) except IOError: self._email = email.message_from_string( warning, policy=email.policy.SMTP) return self._email def get_date(self): """returns Date header value as :class:`~datetime.datetime`""" return self._datetime def get_filename(self): """returns absolute path of message files location""" return self._filename def get_message_id(self): """returns messages id (str)""" return self._id def get_thread_id(self): """returns id (str) of the thread this message belongs to""" return self._thread_id def get_message_parts(self): """yield all body parts of this message""" for msg in self.get_email().walk(): if not msg.is_multipart(): yield msg def get_tags(self): """returns tags attached to this message as list of strings""" return sorted(self._tags) def get_thread(self): """returns the :class:`~alot.db.Thread` this msg belongs to""" if not self._thread: self._thread = self._dbman.get_thread(self._thread_id) return self._thread def has_replies(self): """returns true if this message has at least one reply""" return len(self.get_replies()) > 0 def get_replies(self): """returns replies to this message as list of :class:`Message`""" t = self.get_thread() return t.get_replies_to(self) def get_datestring(self): """ returns reformated datestring for this message. It uses :meth:`SettingsManager.represent_datetime` to represent this messages `Date` header :rtype: str """ if self._datetime is None: res = None else: res = settings.represent_datetime(self._datetime) return res def get_author(self): """ returns realname and address of this messages author :rtype: (str,str) """ return email.utils.parseaddr(self._from) def add_tags(self, tags, afterwards=None, remove_rest=False): """ adds tags to message .. note:: This only adds the requested operation to this objects :class:`DBManager's ` write queue. You need to call :meth:`~alot.db.DBManager.flush` to write out. :param tags: a list of tags to be added :type tags: list of str :param afterwards: callback that gets called after successful application of this tagging operation :type afterwards: callable :param remove_rest: remove all other tags :type remove_rest: bool """ def myafterwards(): if remove_rest: self._tags = set(tags) else: self._tags = self._tags.union(tags) if callable(afterwards): afterwards() self._dbman.tag('id:' + self._id, tags, afterwards=myafterwards, remove_rest=remove_rest) self._tags = self._tags.union(tags) def remove_tags(self, tags, afterwards=None): """remove tags from message .. note:: This only adds the requested operation to this objects :class:`DBManager's ` write queue. You need to call :meth:`~alot.db.DBManager.flush` to actually out. :param tags: a list of tags to be added :type tags: list of str :param afterwards: callback that gets called after successful application of this tagging operation :type afterwards: callable """ def myafterwards(): self._tags = self._tags.difference(tags) if callable(afterwards): afterwards() self._dbman.untag('id:' + self._id, tags, myafterwards) def get_attachments(self): """ returns messages attachments Derived from the leaves of the email mime tree that and are not part of :rfc:`2015` syntax for encrypted/signed mails and either have :mailheader:`Content-Disposition` `attachment` or have :mailheader:`Content-Disposition` `inline` but specify a filename (as parameter to `Content-Disposition`). :rtype: list of :class:`Attachment` """ if not self._attachments: self._attachments = [] for part in self.get_message_parts(): ct = part.get_content_type() # replace underspecified mime description by a better guess if ct in ['octet/stream', 'application/octet-stream']: content = part.get_payload(decode=True) ct = helper.guess_mimetype(content) if (self._attachments and self._attachments[-1].get_content_type() == 'application/pgp-encrypted'): self._attachments.pop() if self._is_attachment(part, ct): self._attachments.append(Attachment(part)) return self._attachments @staticmethod def _is_attachment(part, ct_override=None): """Takes a mimepart and returns a bool indicating if it's an attachment Takes an optional argument to override the content type. """ cd = part.get('Content-Disposition', '') filename = part.get_filename() ct = ct_override or part.get_content_type() if cd.lower().startswith('attachment'): if ct.lower() not in ['application/pgp-signature']: return True elif cd.lower().startswith('inline'): if (filename is not None and ct.lower() != 'application/pgp'): return True return False def get_mime_part(self): if not self._mime_part: self._mime_part = get_body_part(self.get_email()) return self._mime_part def set_mime_part(self, mime_part): self._mime_part = mime_part def get_body_text(self): """ returns bodystring extracted from this mail """ return extract_body_part(self.get_mime_part()) def matches(self, querystring): """tests if this messages is in the resultset for `querystring`""" searchfor = '( {} ) AND id:{}'.format(querystring, self._id) return self._dbman.count_messages(searchfor) > 0 def get_mime_tree(self): if not self._mime_tree: self._mime_tree = self._get_mimetree(self.get_email()) return self._mime_tree @classmethod def _get_mimetree(cls, message): label = cls._get_mime_part_info(message) if message.is_multipart(): return label, [cls._get_mimetree(m) for m in message.get_payload()] else: if cls._is_attachment(message): message = Attachment(message) return label, message @staticmethod def _get_mime_part_info(mime_part): contenttype = mime_part.get_content_type() filename = mime_part.get_filename() or '(no filename)' charset = mime_part.get_content_charset() or '' size = helper.humanize_size(len(mime_part.as_string())) return ' '.join((contenttype, filename, charset, size)) alot-0.11/alot/db/thread.py000066400000000000000000000246511466311112200155440ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from datetime import datetime from ..helper import string_sanitize from .message import Message from ..settings.const import settings from .utils import decode_header class Thread: """ A wrapper around a notmuch mailthread (:class:`notmuch2.Thread`) that ensures persistence of the thread: It can be safely read multiple times, its manipulation is done via a :class:`alot.db.DBManager` and it can directly provide contained messages as :class:`~alot.db.message.Message`. """ def __init__(self, dbman, thread): """ :param dbman: db manager that is used for further lookups :type dbman: :class:`~alot.db.DBManager` :param thread: the wrapped thread :type thread: :class:`notmuch2.Thread` """ self._dbman = dbman self._authors = None self._id = thread.threadid self._messages = {} self._tags = set() self.refresh(thread) def refresh(self, thread=None): """refresh thread metadata from the index""" if not thread: with self._dbman._with_notmuch_thread(self._id) as thread: self._refresh(thread) else: self._refresh(thread) def _refresh(self, thread): self._total_messages = len(thread) self._notmuch_authors_string = thread.authors subject_type = settings.get('thread_subject') if subject_type == 'notmuch': subject = string_sanitize(thread.subject) elif subject_type == 'oldest': try: first_msg = list(thread.toplevel())[0] subject = decode_header(first_msg.header('subject')) except (IndexError, LookupError): subject = '' self._subject = subject self._authors = None ts = thread.first try: self._oldest_date = datetime.fromtimestamp(ts) except ValueError: # year is out of range self._oldest_date = None try: timestamp = thread.last self._newest_date = datetime.fromtimestamp(timestamp) except ValueError: # year is out of range self._newest_date = None self._tags = {t for t in thread.tags} self._messages = {} # this maps messages to its children self._toplevel_messages = [] def __str__(self): return "thread:%s: %s" % (self._id, self.get_subject()) def get_thread_id(self): """returns id of this thread""" return self._id def get_tags(self, intersection=False): """ returns tagsstrings attached to this thread :param intersection: return tags present in all contained messages instead of in at least one (union) :type intersection: bool :rtype: set of str """ tags = set(list(self._tags)) if intersection: for m in self.get_messages().keys(): tags = tags.intersection(set(m.get_tags())) return tags def add_tags(self, tags, afterwards=None, remove_rest=False): """ add `tags` to all messages in this thread .. note:: This only adds the requested operation to this objects :class:`DBManager's ` write queue. You need to call :meth:`DBManager.flush ` to actually write out. :param tags: a list of tags to be added :type tags: list of str :param afterwards: callback that gets called after successful application of this tagging operation :type afterwards: callable :param remove_rest: remove all other tags :type remove_rest: bool """ def myafterwards(): if remove_rest: self._tags = set(tags) else: self._tags = self._tags.union(tags) if callable(afterwards): afterwards() self._dbman.tag('thread:' + self._id, tags, afterwards=myafterwards, remove_rest=remove_rest) def remove_tags(self, tags, afterwards=None): """ remove `tags` (list of str) from all messages in this thread .. note:: This only adds the requested operation to this objects :class:`DBManager's ` write queue. You need to call :meth:`DBManager.flush ` to actually write out. :param tags: a list of tags to be added :type tags: list of str :param afterwards: callback that gets called after successful application of this tagging operation :type afterwards: callable """ rmtags = set(tags).intersection(self._tags) if rmtags: def myafterwards(): self._tags = self._tags.difference(tags) if callable(afterwards): afterwards() self._dbman.untag('thread:' + self._id, tags, myafterwards) self._tags = self._tags.difference(rmtags) def get_authors(self): """ returns a list of authors (name, addr) of the messages. The authors are ordered by msg date and unique (by name/addr). :rtype: list of (str, str) """ if self._authors is None: # Sort messages with date first (by date ascending), and those # without a date last. msgs = sorted(self.get_messages().keys(), key=lambda m: m.get_date() or datetime.max) orderby = settings.get('thread_authors_order_by') self._authors = [] if orderby == 'latest_message': for m in msgs: pair = m.get_author() if pair in self._authors: self._authors.remove(pair) self._authors.append(pair) else: # i.e. first_message for m in msgs: pair = m.get_author() if pair not in self._authors: self._authors.append(pair) return self._authors def get_authors_string(self, own_accts=None, replace_own=None): """ returns a string of comma-separated authors Depending on settings, it will substitute "me" for author name if address is user's own. :param own_accts: list of own accounts to replace :type own_accts: list of :class:`Account` :param replace_own: whether or not to actually do replacement :type replace_own: bool :rtype: str """ if replace_own is None: replace_own = settings.get('thread_authors_replace_me') if replace_own: if own_accts is None: own_accts = settings.get_accounts() authorslist = [] for aname, aaddress in self.get_authors(): for account in own_accts: if account.matches_address(aaddress): aname = settings.get('thread_authors_me') break if not aname: aname = aaddress if aname not in authorslist: authorslist.append(aname) return ', '.join(authorslist) else: return self._notmuch_authors_string def get_subject(self): """returns subject string""" return self._subject def get_toplevel_messages(self): """ returns all toplevel messages contained in this thread. This are all the messages without a parent message (identified by 'in-reply-to' or 'references' header. :rtype: list of :class:`~alot.db.message.Message` """ if not self._messages: self.get_messages() return self._toplevel_messages def get_messages(self): """ returns all messages in this thread as dict mapping all contained messages to their direct responses. :rtype: dict mapping :class:`~alot.db.message.Message` to a list of :class:`~alot.db.message.Message`. """ if not self._messages: # if not already cached with self._dbman._with_notmuch_thread(self._id) as thread: def accumulate(acc, msg): M = Message(self._dbman, msg, thread=self) acc[M] = [] for m in msg.replies(): acc[M].append(accumulate(acc, m)) return M self._messages = {} for m in thread.toplevel(): self._toplevel_messages.append(accumulate(self._messages, m)) return self._messages def get_replies_to(self, msg): """ returns all replies to the given message contained in this thread. :param msg: parent message to look up :type msg: :class:`~alot.db.message.Message` :returns: list of :class:`~alot.db.message.Message` or `None` """ mid = msg.get_message_id() msg_hash = self.get_messages() for m in msg_hash.keys(): if m.get_message_id() == mid: return msg_hash[m] return None def get_newest_date(self): """ returns date header of newest message in this thread as :class:`~datetime.datetime` """ return self._newest_date def get_oldest_date(self): """ returns date header of oldest message in this thread as :class:`~datetime.datetime` """ return self._oldest_date def get_total_messages(self): """returns number of contained messages""" return self._total_messages def matches(self, query): """ Check if this thread matches the given notmuch query. :param query: The query to check against :type query: string :returns: True if this thread matches the given query, False otherwise :rtype: bool """ thread_query = 'thread:{tid} AND {subquery}'.format(tid=self._id, subquery=query) num_matches = self._dbman.count_messages(thread_query) return num_matches > 0 alot-0.11/alot/db/utils.py000066400000000000000000000530411466311112200154300ustar00rootroot00000000000000# encoding=utf-8 # Copyright (C) Patrick Totzke # Copyright © 2017 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import os import email import email.charset as charset import email.policy import email.utils from email.errors import MessageError import tempfile import re import logging import mailcap import io import base64 import quopri from .. import crypto from .. import helper from ..errors import GPGProblem from ..settings.const import settings from ..helper import string_sanitize from ..helper import string_decode from ..helper import parse_mailcap_nametemplate from ..helper import split_commandstring charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') X_SIGNATURE_VALID_HEADER = 'X-Alot-OpenPGP-Signature-Valid' X_SIGNATURE_MESSAGE_HEADER = 'X-Alot-OpenPGP-Signature-Message' _APP_PGP_SIG = 'application/pgp-signature' _APP_PGP_ENC = 'application/pgp-encrypted' def add_signature_headers(mail, sigs, error_msg): '''Add pseudo headers to the mail indicating whether the signature verification was successful. :param mail: :class:`email.message.Message` the message to entitle :param sigs: list of :class:`gpg.results.Signature` :param error_msg: An error message if there is one, or None :type error_msg: :class:`str` or `None` ''' sig_from = '' sig_known = True uid_trusted = False assert error_msg is None or isinstance(error_msg, str) if not sigs: error_msg = error_msg or 'no signature found' elif not error_msg: try: key = crypto.get_key(sigs[0].fpr) for uid in key.uids: if crypto.check_uid_validity(key, uid.email): sig_from = uid.uid uid_trusted = True break else: # No trusted uid found, since we did not break from the loop. sig_from = key.uids[0].uid except GPGProblem: sig_from = sigs[0].fpr sig_known = False if error_msg: msg = 'Invalid: {}'.format(error_msg) elif uid_trusted: msg = 'Valid: {}'.format(sig_from) else: msg = 'Untrusted: {}'.format(sig_from) mail.add_header(X_SIGNATURE_VALID_HEADER, 'False' if (error_msg or not sig_known) else 'True') mail.add_header(X_SIGNATURE_MESSAGE_HEADER, msg) def get_params(mail, failobj=None, header='content-type', unquote=True): '''Get Content-Type parameters as dict. RFC 2045 specifies that parameter names are case-insensitive, so we normalize them here. :param mail: :class:`email.message.Message` :param failobj: object to return if no such header is found :param header: the header to search for parameters, default :param unquote: unquote the values :returns: a `dict` containing the parameters ''' failobj = failobj or [] return {k.lower(): v for k, v in mail.get_params(failobj, header, unquote)} def _handle_signatures(original_bytes, original, message, params): """Shared code for handling message signatures. RFC 3156 is quite strict: * exactly two messages * the second is of type 'application/pgp-signature' * the second contains the detached signature :param original_bytes: the original top-level mail raw bytes, containing the segments against which signatures will be verified. Necessary because parsing and re-serialising a Message isn't byte-perfect, which interferes with signature validation. :type original_bytes: bytes :param original: The original top-level mail. This is required to attache special headers to :type original: :class:`email.message.Message` :param message: The multipart/signed payload to verify :type message: :class:`email.message.Message` :param params: the message parameters as returned by :func:`get_params` :type params: dict[str, str] """ try: nb_parts = len(message.get_payload()) if message.is_multipart() else 1 if nb_parts != 2: raise MessageError( f'expected exactly two messages, got {nb_parts}') signature_part = message.get_payload(1) ct = signature_part.get_content_type() if ct != _APP_PGP_SIG: raise MessageError( f'expected Content-Type: {_APP_PGP_SIG}, got: {ct}') # TODO[dcbaker]: RFC 3156 says the alg has to be lower case, but I've # seen a message with 'PGP-'. maybe we should be more permissive here, # or maybe not, this is crypto stuff... mic_alg = params.get('micalg', 'nothing') if not mic_alg.startswith('pgp-'): raise MessageError(f'expected micalg=pgp-..., got: {mic_alg}') # RFC 3156 section 5 says that "signed message and transmitted message # MUST be identical". We therefore need to validate against the message # as it was originally sent. # The transmitted content and therefore the signed content are using # CRLF as line delimiter, but our eml file has most likely been # converted to UNIX LF line ending in the local storage. if b'\r\n' not in original_bytes: original_bytes = original_bytes.replace(b'\n', b'\r\n') # The sender's signed canonical form often differs from the one # produced by Python's standard lib (in the number of blank lines # between multipart segments...). We therefore need to extract the # signed part directly from the original byte string. signed_boundary = b'\r\n--' + message.get_boundary().encode() original_chunks = original_bytes.split(signed_boundary) nb_chunks = len(original_chunks) if nb_chunks != 4: raise MessageError( f'unexpected number of multipart chunks, got {nb_chunks}') signed_chunk = original_chunks[1] if len(signed_chunk) < len(b'\r\n'): raise MessageError('signed chunk has an invalid length') sigs = crypto.verify_detached( signed_chunk[len(b'\r\n'):], signature_part.get_payload(decode=True)) add_signature_headers(original, sigs, None) except (GPGProblem, MessageError) as error: add_signature_headers(original, [], str(error)) def _handle_encrypted(original, message, session_keys=None): """Handle encrypted messages helper. RFC 3156 is quite strict: * exactly two messages * the first is of type 'application/pgp-encrypted' * the first contains 'Version: 1' * the second is of type 'application/octet-stream' * the second contains the encrypted and possibly signed data :param original: The original top-level mail. This is required to attache special headers to :type original: :class:`email.message.Message` :param message: The multipart/signed payload to verify :type message: :class:`email.message.Message` :param session_keys: a list OpenPGP session keys :type session_keys: [str] """ malformed = False ct = message.get_payload(0).get_content_type() if ct != _APP_PGP_ENC: malformed = 'expected Content-Type: {0}, got: {1}'.format( _APP_PGP_ENC, ct) want = 'application/octet-stream' ct = message.get_payload(1).get_content_type() if ct != want: malformed = 'expected Content-Type: {0}, got: {1}'.format(want, ct) if not malformed: # This should be safe because PGP uses US-ASCII characters only payload = message.get_payload(1).get_payload().encode('ascii') try: sigs, d = crypto.decrypt_verify(payload, session_keys) except GPGProblem as e: # signature verification failures end up here too if the combined # method is used, currently this prevents the interpretation of the # recovered plain text mail. maybe that's a feature. malformed = str(e) else: n = decrypted_message_from_bytes(d, session_keys) # add the decrypted message to message. note that n contains all # the attachments, no need to walk over n here. original.attach(n) original.defects.extend(n.defects) # there are two methods for both signed and encrypted data, one is # called 'RFC 1847 Encapsulation' by RFC 3156, and one is the # 'Combined method'. if not sigs: # 'RFC 1847 Encapsulation', the signature is a detached # signature found in the recovered mime message of type # multipart/signed. if X_SIGNATURE_VALID_HEADER in n: for k in (X_SIGNATURE_VALID_HEADER, X_SIGNATURE_MESSAGE_HEADER): original[k] = n[k] else: # 'Combined method', the signatures are returned by the # decrypt_verify function. # note that if we reached this point, we know the signatures # are valid. if they were not valid, the else block of the # current try would not have been executed add_signature_headers(original, sigs, '') if malformed: msg = 'Malformed OpenPGP message: {0}'.format(malformed) content = email.message_from_string(msg, _class=email.message.EmailMessage, policy=email.policy.SMTP) content.set_charset('utf-8') original.attach(content) def _decrypted_message_from_message(original_bytes, m, session_keys=None): '''Detect and decrypt OpenPGP encrypted data in an email object. If this succeeds, any mime messages found in the recovered plaintext message are added to the returned message object. :param original_bytes: the original top-level mail raw bytes, containing the segments against which signatures will be verified. Necessary because parsing and re-serialising a Message isn't byte-perfect, which interferes with signature validation. :type original_bytes: bytes :param m: an email object :param session_keys: a list OpenPGP session keys :returns: :class:`email.message.Message` possibly augmented with decrypted data ''' # make sure no one smuggles a token in (data from m is untrusted) del m[X_SIGNATURE_VALID_HEADER] del m[X_SIGNATURE_MESSAGE_HEADER] if m.is_multipart(): p = get_params(m) # handle OpenPGP signed data if (m.get_content_subtype() == 'signed' and p.get('protocol') == _APP_PGP_SIG): _handle_signatures(original_bytes, m, m, p) # handle OpenPGP encrypted data elif (m.get_content_subtype() == 'encrypted' and p.get('protocol') == _APP_PGP_ENC and 'Version: 1' in m.get_payload(0).get_payload()): _handle_encrypted(m, m, session_keys) # It is also possible to put either of the abov into a multipart/mixed # segment elif m.get_content_subtype() == 'mixed': sub = m.get_payload(0) if sub.is_multipart(): p = get_params(sub) if (sub.get_content_subtype() == 'signed' and p.get('protocol') == _APP_PGP_SIG): _handle_signatures(original_bytes, m, sub, p) elif (sub.get_content_subtype() == 'encrypted' and p.get('protocol') == _APP_PGP_ENC): _handle_encrypted(m, sub, session_keys) return m def decrypted_message_from_bytes(bytestring, session_keys=None): """Create a Message from bytes. :param bytes bytestring: an email message as raw bytes :param session_keys: a list OpenPGP session keys """ return _decrypted_message_from_message( bytestring, email.message_from_bytes(bytestring, _class=email.message.EmailMessage, policy=email.policy.SMTP), session_keys) def extract_headers(mail, headers=None): """ returns subset of this messages headers as human-readable format: all header values are decoded, the resulting string has one line "KEY: VALUE" for each requested header present in the mail. :param mail: the mail to use :type mail: :class:`email.message.EmailMessage` :param headers: headers to extract :type headers: list of str """ headertext = '' if headers is None: headers = mail.keys() for key in headers: value = '' if key in mail: value = decode_header(mail.get(key, '')) headertext += '%s: %s\n' % (key, value) return headertext def render_part(part, field_key='copiousoutput'): """ renders a non-multipart email part into displayable plaintext by piping its payload through an external script. The handler itself is determined by the mailcap entry for this part's ctype. """ ctype = part.get_content_type() raw_payload = remove_cte(part) rendered_payload = None # get mime handler _, entry = settings.mailcap_find_match(ctype, key=field_key) if entry is not None: tempfile_name = None stdin = None handler_raw_commandstring = entry['view'] # in case the mailcap defined command contains no '%s', # we pipe the files content to the handling command via stdin if '%s' in handler_raw_commandstring: # open tempfile, respect mailcaps nametemplate nametemplate = entry.get('nametemplate', '%s') prefix, suffix = parse_mailcap_nametemplate(nametemplate) with tempfile.NamedTemporaryFile( delete=False, prefix=prefix, suffix=suffix) \ as tmpfile: tmpfile.write(raw_payload) tempfile_name = tmpfile.name else: stdin = raw_payload # read parameter, create handler command parms = tuple('='.join(p) for p in part.get_params(failobj=[])) # create and call external command cmd = mailcap.subst(entry['view'], ctype, filename=tempfile_name, plist=parms) logging.debug('command: %s', cmd) logging.debug('parms: %s', str(parms)) cmdlist = split_commandstring(cmd) # call handler stdout, _, _ = helper.call_cmd(cmdlist, stdin=stdin) if stdout: rendered_payload = stdout # remove tempfile if tempfile_name: os.unlink(tempfile_name) return rendered_payload def remove_cte(part, as_string=False): """Interpret MIME-part according to it's Content-Transfer-Encodings. This returns the payload of `part` as string or bytestring for display, or to be passed to an external program. In the raw file the payload may be encoded, e.g. in base64, quoted-printable, 7bit, or 8bit. This method will look for one of the above Content-Transfer-Encoding header and interpret the payload accordingly. Incorrect header values (common in spam messages) will be interpreted as lenient as possible and will result in INFO-level debug messages. ..Note:: All this may be depricated in favour of `email.contentmanager.raw_data_manager` (v3.6+) :param email.message.EmailMessage part: The part to decode :param bool as_string: If true return a str, otherwise return bytes :returns: The mail with any Content-Transfer-Encoding removed :rtype: Union[str, bytes] """ enc = part.get_content_charset() or 'ascii' cte = str(part.get('content-transfer-encoding', '7bit')).lower().strip() payload = part.get_payload() sp = '' # string variant of return value bp = b'' # bytestring variant logging.debug('Content-Transfer-Encoding: "{}"'.format(cte)) if cte not in ['quoted-printable', 'base64', '7bit', '8bit', 'binary']: logging.info('Unknown Content-Transfer-Encoding: "{}"'.format(cte)) # switch through all sensible cases # starting with those where payload is already a str if '7bit' in cte or 'binary' in cte: logging.debug('assuming Content-Transfer-Encoding: 7bit') sp = payload if as_string: return sp bp = payload.encode('utf-8') return bp # the remaining cases need decoding and define only bt; # decoding into a str is done at the end if requested elif '8bit' in cte: logging.debug('assuming Content-Transfer-Encoding: 8bit') bp = payload.encode('utf8') elif 'quoted-printable' in cte: logging.debug('assuming Content-Transfer-Encoding: quoted-printable') bp = quopri.decodestring(payload.encode('ascii')) elif 'base64' in cte: logging.debug('assuming Content-Transfer-Encoding: base64') bp = base64.b64decode(payload) else: logging.debug('failed to interpret Content-Transfer-Encoding: ' '"{}"'.format(cte)) # by now, bp is defined, sp is not. if as_string: try: sp = bp.decode(enc) except LookupError: # enc is unknown; # fall back to guessing the correct encoding using libmagic sp = helper.try_decode(bp) except UnicodeDecodeError as emsg: # the mail contains chars that are not enc-encoded. # libmagic works better than just ignoring those logging.debug('Decoding failure: {}'.format(emsg)) sp = helper.try_decode(bp) return sp return bp MISSING_HTML_MSG = ("This message contains a text/html part that was not " "rendered due to a missing mailcap entry. " "Please refer to item 1 in our FAQ: " "https://alot.rtfd.io/en/latest/faq.html") def get_body_part(mail, mimetype=None): """Returns an EmailMessage. This consults :ref:`prefer_plaintext ` to determine if a "text/plain" alternative is preferred over a "text/html" part. :param mail: the mail to use :type mail: :class:`email.message.EmailMessage` :returns: The combined text of any parts to be used :rtype: str """ if not mimetype: mimetype = 'plain' if settings.get('prefer_plaintext') else 'html' preferencelist = { 'plain': ('plain', 'html'), 'html': ('html', 'plain')}[mimetype] body_part = mail.get_body(preferencelist) if body_part is None: # if no part matching preferredlist was found return "" return body_part def extract_body_part(body_part): """Returns a string view of a Message.""" displaystring = "" rendered_payload = render_part( body_part, **{'field_key': 'view'} if body_part.get_content_type() == 'text/plain' else {}) if rendered_payload: # handler had output displaystring = string_sanitize(rendered_payload) elif body_part.get_content_type() == 'text/plain': displaystring = string_sanitize(remove_cte(body_part, as_string=True)) else: if body_part.get_content_type() == 'text/html': displaystring = MISSING_HTML_MSG return displaystring def formataddr(pair): """ this is the inverse of email.utils.parseaddr: other than email.utils.formataddr, this - *will not* re-encode unicode strings, and - *will* re-introduce quotes around real names containing commas """ name, address = pair if not name: return address elif ',' in name: name = "\"" + name + "\"" return "{0} <{1}>".format(name, address) def decode_header(header, normalize=False): """ decode a header value to a unicode string values are usually a mixture of different substrings encoded in quoted printable using different encodings. This turns it into a single unicode string :param header: the header value :type header: str :param normalize: replace trailing spaces after newlines :type normalize: bool :rtype: str """ logging.debug("unquoted header: |%s|", header) valuelist = email.header.decode_header(header) decoded_list = [] for v, enc in valuelist: v = string_decode(v, enc) decoded_list.append(string_sanitize(v)) value = ''.join(decoded_list) if normalize: value = re.sub(r'\n\s+', r' ', value) return value def is_subdir_of(subpath, superpath): # make both absolute superpath = os.path.realpath(superpath) subpath = os.path.realpath(subpath) # return true, if the common prefix of both is equal to directory # e.g. /a/b/c/d.rst and directory is /a/b, the common prefix is /a/b return os.path.commonprefix([subpath, superpath]) == superpath def clear_my_address(my_account, value): """return recipient header without the addresses in my_account :param my_account: my account :type my_account: :class:`Account` :param value: a list of recipient or sender strings (with or without real names as taken from email headers) :type value: list(str) :returns: a new, potentially shortend list :rtype: list(str) """ new_value = [] for name, address in email.utils.getaddresses(value): if not my_account.matches_address(address): new_value.append(formataddr((name, address))) return new_value def ensure_unique_address(recipients): """ clean up a list of name,address pairs so that no address appears multiple times. """ res = dict() for name, address in email.utils.getaddresses(recipients): res[address] = name logging.debug(res) urecipients = [formataddr((n, a)) for a, n in res.items()] return sorted(urecipients) alot-0.11/alot/defaults/000077500000000000000000000000001466311112200151355ustar00rootroot00000000000000alot-0.11/alot/defaults/abook_contacts.spec000066400000000000000000000002041466311112200207760ustar00rootroot00000000000000[format] program = string version = string [__many__] name = string(default=None) email = force_list(default=list()) alot-0.11/alot/defaults/alot.rc.spec000066400000000000000000000517011466311112200173570ustar00rootroot00000000000000 ask_subject = boolean(default=True) # ask for subject when compose # automatically remove 'unread' tag when focussing messages in thread mode auto_remove_unread = boolean(default=True) # prompt for initial tags when compose compose_ask_tags = boolean(default=False) # directory prefix for downloading attachments attachment_prefix = string(default='~') # timeout in (floating point) seconds until partial input is cleared input_timeout = float(default=1.0) # A list of tags that will be excluded from search results by default. Using an excluded tag in a query will override that exclusion. # .. note:: when set, this config setting will overrule the 'search.exclude_tags' in the notmuch config. exclude_tags = force_list(default=None) # display background colors set by ANSI character escapes interpret_ansi_background = boolean(default=True) # confirm exit bug_on_exit = boolean(default=False) # offset of next focused buffer if the current one gets closed bufferclose_focus_offset = integer(default=-1) # number of colours to use on the terminal colourmode = option(1, 16, 256, default=256) # number of spaces used to replace tab characters tabwidth = integer(default=8) # templates directory that contains your message templates. # It will be used if you give `compose --template` a filename without a path prefix. template_dir = string(default='$XDG_CONFIG_HOME/alot/templates') # directory containing theme files. themes_dir = string(default='$XDG_CONFIG_HOME/alot/themes') # name of the theme to use theme = string(default=None) # enable mouse support - mouse tracking will be handled by urwid # # .. note:: If this is set to True mouse events are passed from the terminal # to urwid/alot. This means that normal text selection in alot will # not be possible. Most terminal emulators will still allow you to # select text when shift is pressed. handle_mouse = boolean(default=False) # headers that get displayed by default displayed_headers = force_list(default=list(From,To,Cc,Bcc,Subject)) # headers that are hidden in envelope buffers by default envelope_headers_blacklist = force_list(default=list(In-Reply-To,References)) # Replace own email addresses with "me" in author lists # Uses own addresses and aliases in all configured accounts. thread_authors_replace_me = boolean(default=True) # Word to replace own addresses with. Works in combination with # :ref:`thread_authors_replace_me ` thread_authors_me = string(default='Me') # What should be considered to be "the thread subject". # Valid values are: # # * 'notmuch' (the default), will use the thread subject from notmuch, which # depends on the selected sorting method # * 'oldest' will always use the subject of the oldest message in the thread as # the thread subject thread_subject = option('oldest', 'notmuch', default='notmuch') # When constructing the unique list of thread authors, order by date of # author's first or latest message in thread thread_authors_order_by = option('first_message', 'latest_message', default='first_message') # number of characters used to indent replies relative to original messages in thread mode thread_indent_replies = integer(default=2) # set terminal command used for spawning shell commands terminal_cmd = string(default='x-terminal-emulator -e') # editor command # if unset, alot will first try the :envvar:`EDITOR` env variable, then :file:`/usr/bin/editor` editor_cmd = string(default=None) # file encoding used by your editor editor_writes_encoding = string(default='UTF-8') # use :ref:`terminal_cmd ` to spawn a new terminal for the editor? # equivalent to always providing the `--spawn=yes` parameter to compose/edit commands editor_spawn = boolean(default=False) # call editor in separate thread. # In case your editor doesn't run in the same window as alot, setting true here # will make alot non-blocking during edits editor_in_thread = boolean(default=False) # Which header fields should be editable in your editor # used are those that match the whitelist and don't match the blacklist. # in both cases '*' may be used to indicate all fields. edit_headers_whitelist = force_list(default=list(*,)) # see :ref:`edit_headers_whitelist ` edit_headers_blacklist = force_list(default=list(Content-Type,MIME-Version,References,In-Reply-To)) # timeout in seconds after a failed attempt to writeout the database is # repeated. Set to 0 for no retry. flush_retry_timeout = integer(default=5) # where to look up hooks hooksfile = string(default=None) # time in secs to display status messages notify_timeout = integer(default=2) # display status-bar at the bottom of the screen? show_statusbar = boolean(default=True) # Format of the status-bar in bufferlist mode. # This is a pair of strings to be left and right aligned in the status-bar that may contain variables: # # * `{buffer_no}`: index of this buffer in the global buffer list # * `{total_messages}`: total numer of messages indexed by notmuch # * `{pending_writes}`: number of pending write operations to the index bufferlist_statusbar = mixed_list(string, string, default=list('[{buffer_no}: bufferlist]','{input_queue} total messages: {total_messages}')) # Format of the status-bar in search mode. # This is a pair of strings to be left and right aligned in the status-bar. # Apart from the global variables listed at :ref:`bufferlist_statusbar ` # these strings may contain variables: # # * `{querystring}`: search string # * `{result_count}`: number of matching messages # * `{result_count_positive}`: 's' if result count is greater than 0. search_statusbar = mixed_list(string, string, default=list('[{buffer_no}: search] for "{querystring}"','{input_queue} {result_count} of {total_messages} messages')) # Format of the status-bar in thread mode. # This is a pair of strings to be left and right aligned in the status-bar. # Apart from the global variables listed at :ref:`bufferlist_statusbar ` # these strings may contain variables: # # * `{tid}`: thread id # * `{subject}`: subject line of the thread # * `{authors}`: abbreviated authors string for this thread # * `{message_count}`: number of contained messages # * `{thread_tags}`: displays all tags present in the current thread. # * `{intersection_tags}`: displays tags common to all messages in the current thread. # * `{mimetype}`: content type of the mime part displayed in the focused message. thread_statusbar = mixed_list(string, string, default=list('[{buffer_no}: thread] {subject}','[{mimetype}] {input_queue} total messages: {total_messages}')) # Format of the status-bar in taglist mode. # This is a pair of strings to be left and right aligned in the status-bar. # These strings may contain variables listed at :ref:`bufferlist_statusbar ` # that will be substituted accordingly. taglist_statusbar = mixed_list(string, string, default=list('[{buffer_no}: taglist]','{input_queue} total messages: {total_messages}')) # Format of the status-bar in named query list mode. # This is a pair of strings to be left and right aligned in the status-bar. # These strings may contain variables listed at :ref:`bufferlist_statusbar ` # that will be substituted accordingly. namedqueries_statusbar = mixed_list(string, string, default=list('[{buffer_no}: namedqueries]','{query_count} named queries')) # Format of the status-bar in envelope mode. # This is a pair of strings to be left and right aligned in the status-bar. # Apart from the global variables listed at :ref:`bufferlist_statusbar ` # these strings may contain variables: # # * `{to}`: To-header of the envelope # * `{displaypart}`: which body part alternative is currently in view (can be 'plaintext,'src', or 'html') envelope_statusbar = mixed_list(string, string, default=list('[{buffer_no}: envelope ({displaypart})]','{input_queue} total messages: {total_messages}')) # timestamp format in `strftime format syntax `_ timestamp_format = string(default=None) # how to print messages: # this specifies a shell command used for printing. # threads/messages are piped to this command as plain text. # muttprint/a2ps works nicely print_cmd = string(default=None) # initial command when none is given as argument: initial_command = string(default='search tag:inbox AND NOT tag:killed') # default sort order of results in a search search_threads_sort_order = option('oldest_first', 'newest_first', 'message_id', 'unsorted', default='newest_first') # maximum amount of threads that will be consumed to try to restore the focus, upon triggering a search buffer rebuild # when set to 0, no limit is set (can be very slow in searches that yield thousands of results) search_threads_rebuild_limit = integer(default=0) # Maximum number of results in a search buffer before 'move last' builds the # thread list in reversed order as a heuristic. The resulting order will be # different for threads with multiple matching messages. # When set to 0, no limit is set (can be very slow in searches that yield thousands of results) search_threads_move_last_limit = integer(default=200) # in case more than one account has an address book: # Set this to True to make tab completion for recipients during compose only # look in the abook of the account matching the sender address complete_matching_abook_only = boolean(default=False) # shut down when the last buffer gets closed quit_on_last_bclose = boolean(default=False) # value of the User-Agent header used for outgoing mails. # setting this to the empty string will cause alot to omit the header all together. # The string '{version}' will be replaced by the version string of the running instance. user_agent = string(default='alot/{version}') # Suffix of the prompt used when waiting for user input prompt_suffix = string(default=':') # String prepended to line when quoting quote_prefix = string(default='> ') # String prepended to subject header on reply # only if original subject doesn't start with 'Re:' or this prefix reply_subject_prefix = string(default='Re: ') # String prepended to subject header on forward # only if original subject doesn't start with 'Fwd:' or this prefix forward_subject_prefix = string(default='Fwd: ') # Always use the proper realname when constructing "From" headers for replies. # Set this to False to use the realname string as received in the original message. reply_force_realname = boolean(default=True) # Always use the accounts main address when constructing "From" headers for replies. # Set this to False to use the address string as received in the original message. reply_force_address = boolean(default=False) # Always use the proper realname when constructing "From" headers for forwards. # Set this to False to use the realname string as received in the original message. forward_force_realname = boolean(default=True) # Always use the accounts main address when constructing "From" headers for forwards. # Set this to False to use the address string as received in the original message. forward_force_address = boolean(default=False) # Always use the proper realname when constructing "Resent-From" headers for bounces. # Set this to False to use the realname string as received in the original message. bounce_force_realname = boolean(default=True) # Always use the accounts main address when constructing "Resent-From" headers for bounces. # Set this to False to use the address string as received in the original message. bounce_force_address = boolean(default=False) # When group-reply-ing to an email that has the "Mail-Followup-To" header set, # use the content of this header as the new "To" header and leave the "Cc" # header empty honor_followup_to = boolean(default=False) # When one of the recipients of an email is a subscribed mailing list, set the # "Mail-Followup-To" header to the list of recipients without yourself followup_to = boolean(default=False) # The list of addresses associated to the mailinglists you are subscribed to mailinglists = force_list(default=list()) # Automatically switch to list reply mode if appropriate auto_replyto_mailinglist = boolean(default=False) # prefer plaintext alternatives over html content in multipart/alternative prefer_plaintext = boolean(default=False) # always edit the given body text alternative when editing outgoing messages in envelope mode. # alternative, and not the html source, even if that is currently displayed. # If unset, html content will be edited unless the current envelope shows the plaintext alternative. envelope_edit_default_alternative = option('plaintext', 'html', default=None) # Use this command to construct a html alternative message body text in envelope mode. # If unset, we send only the plaintext part, without html alternative. # The command will receive the plaintex on stdin and should produce html on stdout. # (as `pandoc -t html` does for example). envelope_txt2html = string(default=None) # Use this command to turn a html message body to plaintext in envelope mode. # The command will receive the html on stdin and should produce text on stdout # (as `pandoc -f html -t markdown` does for example). envelope_html2txt = string(default=None) # In a thread buffer, hide from messages summaries tags that are commom to all # messages in that thread. msg_summary_hides_threadwide_tags = boolean(default=True) # The list of headers to match to determine sending account for a reply. # Headers are searched in the order in which they are specified here, and the first header # containing a match is used. If multiple accounts match in that header, the one defined # first in the account block is used. reply_account_header_priority = force_list(default=list(From,To,Cc,Envelope-To,X-Envelope-To,Delivered-To)) # The number of command line history entries to save # # .. note:: You can set this to -1 to save *all* entries to disk but the # history file might get *very* long. history_size = integer(default=50) # The number of seconds to wait between calls to the loop_hook periodic_hook_frequency = integer(default=300) # Split message body linewise and allows to (move) the focus to each individual # line. Setting this to False will result in one potentially big text widget # for the whole message body. thread_focus_linewise = boolean(default=True) # Unfold messages matching the query. If not set, will unfold all messages matching search buffer query. thread_unfold_matching = string(default=None) # Key bindings [bindings] __many__ = string(default=None) [[___many___]] __many__ = string(default=None) [tags] # for each tag [[__many__]] # unfocussed normal = attrtriple(default=None) # focussed focus = attrtriple(default=None) # don't display at all? hidden = boolean(default=False) # alternative string representation translated = string(default=None) # substitution to generate translated from section name translation = mixed_list(string, string, default=None) [accounts] [[__many__]] # your main email address address = string # used to format the (proposed) From-header in outgoing mails realname = string # used to clear your addresses/ match account when formatting replies aliases = force_list(default=list()) # a regex for catching further aliases (like + extensions). alias_regexp = string(default=None) # sendmail command. This is the shell command used to send out mails via the sendmail protocol sendmail_command = string(default='sendmail -t') # where to store outgoing mails, e.g. `maildir:///home/you/mail/Sent`, # `maildir://$MAILDIR/Sent` or `maildir://~/mail/Sent`. # You can use mbox, maildir, mh, babyl and mmdf in the protocol part of the URL. # # .. note:: If you want to add outgoing mails automatically to the notmuch index # you must use maildir in a path within your notmuch database path. sent_box = mail_container(default=None) # where to store draft mails, e.g. `maildir:///home/you/mail/Drafts`, # `maildir://$MAILDIR/Drafts` or `maildir://~/mail/Drafts`. # You can use mbox, maildir, mh, babyl and mmdf in the protocol part of the URL. # # .. note:: You will most likely want drafts indexed by notmuch to be able to # later access them within alot. This currently only works for # maildir containers in a path below your notmuch database path. draft_box = mail_container(default=None) # list of tags to automatically add to outgoing messages sent_tags = force_list(default='sent') # list of tags to automatically add to draft messages draft_tags = force_list(default='draft') # list of tags to automatically add to replied messages replied_tags = force_list(default='replied') # list of tags to automatically add to passed messages passed_tags = force_list(default='passed') # path to signature file that gets attached to all outgoing mails from this account, optionally # renamed to :ref:`signature_filename `. signature = string(default=None) # attach signature file if set to True, append its content (mimetype text) # to the body text if set to False. signature_as_attachment = boolean(default=False) # signature file's name as it appears in outgoing mails if # :ref:`signature_as_attachment ` is set to True signature_filename = string(default=None) # Outgoing messages will be GPG signed by default if this is set to True. sign_by_default = boolean(default=False) # Alot will try to GPG encrypt outgoing messages by default when this # is set to `all` or `trusted`. If set to `all` the message will be # encrypted for all recipients for who a key is available in the key # ring. If set to `trusted` it will be encrypted to all # recipients if a trusted key is available for all recipients (one # where the user id for the key is signed with a trusted signature). # # .. note:: If the message will not be encrypted by default you can # still use the :ref:`toggleencrypt # `, :ref:`encrypt # ` and :ref:`unencrypt # ` commands to encrypt it. # .. deprecated:: 0.4 # The values `True` and `False` are interpreted as `all` and # `none` respectively. `0`, `1`, `true`, `True`, `false`, # `False`, `yes`, `Yes`, `no`, `No`, will be removed before # 1.0, please move to `all`, `none`, or `trusted`. encrypt_by_default = option('all', 'none', 'trusted', 'True', 'False', 'true', 'false', 'Yes', 'No', 'yes', 'no', '1', '0', default='none') # If this is true when encrypting a message it will also be encrypted # with the key defined for this account. # # .. warning:: # # Before 0.6 this was controlled via gpg.conf. encrypt_to_self = boolean(default=True) # The GPG key ID you want to use with this account. gpg_key = gpg_key_hint(default=None) # Whether the server treats the address as case-senstive or # case-insensitve (True for the former, False for the latter) # # .. note:: The vast majority (if not all) SMTP servers in modern use # treat usernames as case insenstive, you should only set # this if you know that you need it. case_sensitive_username = boolean(default=False) # Domain to use in automatically generated Message-ID headers. # The default is the local hostname. message_id_domain = string(default=None) # address book for this account [[[abook]]] # type identifier for address book type = option('shellcommand', 'abook', default=None) # make case-insensitive lookups ignorecase = boolean(default=True) # command to lookup contacts in shellcommand abooks # it will be called with the lookup prefix as only argument command = string(default=None) # regular expression used to match name/address pairs in the output of `command` # for shellcommand abooks regexp = string(default=None) # contacts file used for type 'abook' address book abook_contacts_file = string(default='~/.abook/addressbook') # (shellcommand addressbooks) # let the external command do the filtering when looking up addresses. # If set to True, the command is fired with the given search string # as parameter. Otherwise, the command is fired without additional parameters # and the result list is filtered according to the search string. shellcommand_external_filtering = boolean(default=True) alot-0.11/alot/defaults/default.bindings000066400000000000000000000032011466311112200202740ustar00rootroot00000000000000up = move up down = move down page up = move page up page down = move page down mouse press 4 = move up mouse press 5 = move down j = move down k = move up 'g g' = move first G = move last ' ' = move page down 'ctrl d' = move halfpage down 'ctrl u' = move halfpage up @ = refresh ? = help bindings I = search tag:inbox AND NOT tag:killed '#' = taglist shift tab = bprevious U = search tag:unread tab = bnext \ = prompt 'search ' d = bclose $ = flush m = compose o = prompt 'search ' q = exit ';' = bufferlist ':' = prompt . = repeat [bufferlist] x = close enter = open [search] enter = select a = toggletags inbox & = toggletags killed ! = toggletags flagged s = toggletags unread l = retagprompt O = refineprompt | = refineprompt [envelope] a = prompt 'attach ~/' y = send P = save s = 'refine Subject' f = prompt 'set From ' t = 'refine To' b = 'refine Bcc' c = 'refine Cc' S = togglesign enter = edit 'g f' = togglesource [taglist] enter = select [namedqueries] enter = select [thread] enter = select C = fold * E = unfold * c = fold e = unfold < = fold > = unfold [ = indent - ] = indent + 'g f' = togglesource H = toggleheaders P = print --all --separately --add_tags S = save --all g = reply --all f = forward p = print --add_tags n = editnew b= bounce s = save r = reply | = prompt 'pipeto ' t = togglemimetree h = togglemimepart 'g j' = move next sibling 'g k' = move previous sibling 'g h' = move parent 'g l' = move first reply ' ' = move next alot-0.11/alot/defaults/default.theme000066400000000000000000000120411466311112200176030ustar00rootroot00000000000000############################################################################ # Default Theme # # # # for alot. © 2012 Patrick Totzke, GNU GPL3+, https://github.com/pazz/alot # ############################################################################ [global] footer = 'standout','','white,bold','dark blue','white,bold','#006' body = 'default','','default','default','default','default' notify_error = 'standout','','white','dark red','white','dark red' notify_normal = 'default','','light gray','dark gray','light gray','#68a' prompt = 'default','','light gray','black','light gray','g11' tag = 'default','','dark gray','black','dark gray','default' tag_focus = 'standout, bold','','white','dark gray','#ffa','#68a' [help] text = 'default','','default','dark gray','default','g35' section = 'underline','','bold,underline','dark gray','bold,underline','g35' title = 'standout','','white','dark blue','white,bold,underline','g35' [bufferlist] line_focus = 'standout','','yellow','light gray','#ff8','g58' line_even = 'default','','light gray','black','default','default' line_odd = 'default','','light gray','black','default','default' [taglist] line_focus = 'standout','','yellow','light gray','#ff8','g58' line_even = 'default','','light gray','black','default','default' line_odd = 'default','','light gray','black','default','default' [namedqueries] line_focus = 'standout','','yellow','light gray','#ff8','g58' line_even = 'default','','light gray','black','default','default' line_odd = 'default','','light gray','black','default','default' [thread] arrow_heads = '','','dark red','','#a00','' arrow_bars = '','','dark red','','#800','' attachment = 'default','','light gray','dark gray','light gray','dark gray' attachment_focus = 'underline','','light gray','light green','light gray','dark gray' body = 'default','','default','default','default','default' body_focus = 'default','','default,standout','default','default,standout','default' header = 'default','','white','dark gray','white','dark gray' header_key = 'default','','white','dark gray','white','dark gray' header_value = 'default','','light gray','dark gray','light gray','dark gray' [[summary]] even = 'default','','white','light blue','white','#006' odd = 'default','','white','dark blue','white','#068' focus = 'standout','','white','light gray','#ff8','g58' [envelope] body = 'default','','light gray','default','light gray','default' header = 'default','','white','dark gray','white','dark gray' header_key = 'default','','white','dark gray','white','dark gray' header_value = 'default','','light gray','dark gray','light gray','dark gray' [search] [[threadline]] normal = 'default','','default','default','#6d6','default' focus = 'standout','','light gray','dark gray','g85','g58' parts = date,mailcount,tags,authors,subject [[[date]]] normal = 'default','','default','default','default','default' focus = 'standout','','yellow','light gray','yellow','g58' width = 'fit',10,10 alignment = right [[[mailcount]]] normal = 'default','','light gray','default','g66','default' focus = 'standout','','yellow','light gray','yellow','g58' width = 'fit', 5,5 [[[tags]]] normal = 'bold','','dark cyan','','dark cyan','' focus = 'standout','','yellow','light gray','yellow','g58' [[[authors]]] normal = 'default,underline','','light blue','default','#068','default' focus = 'standout','','yellow','light gray','yellow','g58' width = 'fit',0,30 [[[subject]]] normal = 'default','','light gray','default','g66','default' focus = 'standout','','yellow','light gray','yellow','g58' width = 'weight', 1 [[[content]]] normal = 'default','','light gray','default','dark gray','default' focus = 'standout','','yellow','light gray','yellow','g58' width = 'weight', 1 # highlight threads containing unread messages [[threadline-unread]] tagged_with = 'unread' normal = 'default','','default,bold','default','#6d6,bold','default' parts = date,mailcount,tags,authors,subject [[[date]]] normal = 'default','','default,bold','default','default','default' [[[mailcount]]] normal = 'default','','default,bold','default','default','default' [[[tags]]] normal = 'bold','','dark cyan,bold','','#6dd','' [[[authors]]] normal = 'default,underline','','light blue,bold','default','#68f','default' [[[subject]]] normal = 'default','','default,bold','default','default','default' [[[content]]] normal = 'default','','light gray,bold','default','dark gray,bold','default' alot-0.11/alot/defaults/theme.spec000066400000000000000000000040151466311112200171130ustar00rootroot00000000000000[global] # attributes used in all modi footer = attrtriple body = attrtriple notify_error = attrtriple notify_normal = attrtriple prompt = attrtriple tag = attrtriple tag_focus = attrtriple [help] # formatting of the `help bindings` overlay text = attrtriple section = attrtriple title = attrtriple # mode specific attributes [bufferlist] line_focus = attrtriple line_even = attrtriple line_odd = attrtriple [taglist] line_focus = attrtriple line_even = attrtriple line_odd = attrtriple [namedqueries] line_focus = attrtriple line_even = attrtriple line_odd = attrtriple [search] [[threadline]] normal = attrtriple focus = attrtriple # list of subwidgets to display. Every element listed must have its # own subsection below. Valid elements are authors, content, date, # mailcount, tags, and subject. parts = string_list(default=None) [[[__many__]]] normal = attrtriple focus = attrtriple width = widthtuple(default=None) alignment = align(default='left') [[__many__]] normal = attrtriple(default=None) focus = attrtriple(default=None) parts = string_list(default=None) query = string(default=None) tagged_with = force_list(default=None) [[[__many__]]] normal = attrtriple(default=None) focus = attrtriple(default=None) width = widthtuple(default=None) alignment = align(default=None) [thread] arrow_heads = attrtriple arrow_bars = attrtriple attachment = attrtriple attachment_focus = attrtriple body = attrtriple body_focus = attrtriple(default=None) header = attrtriple header_key = attrtriple header_value = attrtriple [[summary]] even = attrtriple odd = attrtriple focus = attrtriple [envelope] body = attrtriple header = attrtriple header_key = attrtriple header_value = attrtriple alot-0.11/alot/errors.py000066400000000000000000000012721466311112200152160ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file class GPGCode: AMBIGUOUS_NAME = 1 NOT_FOUND = 2 BAD_PASSPHRASE = 3 KEY_REVOKED = 4 KEY_EXPIRED = 5 KEY_INVALID = 6 KEY_CANNOT_ENCRYPT = 7 KEY_CANNOT_SIGN = 8 INVALID_HASH = 9 INVALID_HASH_ALGORITHM = 10 BAD_SIGNATURE = 11 class GPGProblem(Exception): """GPG Error""" def __init__(self, message, code): self.code = code super(GPGProblem, self).__init__(message) class CompletionError(Exception): pass class ConversionError(Exception): pass alot-0.11/alot/helper.py000066400000000000000000000462631466311112200151720ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright (C) Patrick Totzke # Copyright © 2017-2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from datetime import timedelta from datetime import datetime from collections import deque import logging import os import re import shlex import subprocess import unicodedata import email from email.mime.audio import MIMEAudio from email.mime.base import MIMEBase from email.mime.image import MIMEImage from email.mime.text import MIMEText import asyncio import urwid import magic def split_commandline(s): """ splits semi-colon separated commandlines, ignoring quoted separators """ splitter = r'''((?:[^;"']|"(\\\\|\\"|[^"])*"|'(\\\\|\\'|[^'])*')+)''' return re.split(splitter, s)[1::4] def split_commandstring(cmdstring): """ split command string into a list of strings to pass on to subprocess.Popen and the like. This simply calls shlex.split but works also with unicode bytestrings. """ assert isinstance(cmdstring, str) return shlex.split(cmdstring) def unicode_printable(c): """ Checks if the given character is a printable Unicode character, i.e., not a private/unassigned character and not a control character other than tab or newline. """ if c in ('\n', '\t'): return True return unicodedata.category(c) not in ('Cc', 'Cn', 'Co') def string_sanitize(string, tab_width=8): r""" strips, and replaces non-printable characters :param tab_width: number of spaces to replace tabs with. Read from `globals.tabwidth` setting if `None` :type tab_width: int or `None` >>> string_sanitize(' foo\rbar ', 8) ' foobar ' >>> string_sanitize('foo\tbar', 8) 'foo bar' >>> string_sanitize('foo\t\tbar', 8) 'foo bar' """ string = ''.join([c for c in string if unicode_printable(c)]) lines = list() for line in string.split('\n'): tab_count = line.count('\t') if tab_count > 0: line_length = 0 new_line = list() for i, chunk in enumerate(line.split('\t')): line_length += len(chunk) new_line.append(chunk) if i < tab_count: next_tab_stop_in = tab_width - (line_length % tab_width) new_line.append(' ' * next_tab_stop_in) line_length += next_tab_stop_in lines.append(''.join(new_line)) else: lines.append(line) return '\n'.join(lines) def string_decode(string, enc='ascii'): """ safely decodes string to unicode bytestring, respecting `enc` as a hint. :param string: the string to decode :type string: str or unicode :param enc: a hint what encoding is used in string ('ascii', 'utf-8', ...) :type enc: str :returns: the unicode decoded input string :rtype: unicode """ if enc is None: enc = 'ascii' try: string = str(string, enc, errors='replace') except LookupError: # malformed enc string string = string.decode('ascii', errors='replace') except TypeError: # already str pass return string def shorten(string, maxlen): """shortens string if longer than maxlen, appending ellipsis""" if 1 < maxlen < len(string): string = string[:maxlen - 1] + '…' return string[:maxlen] def shorten_author_string(authors_string, maxlength): """ Parse a list of authors concatenated as a text string (comma separated) and smartly adjust them to maxlength. 1) If the complete list of sender names does not fit in maxlength, it tries to shorten names by using only the first part of each. 2) If the list is still too long, hide authors according to the following priority: - First author is always shown (if too long is shorten with ellipsis) - If possible, last author is also shown (if too long, uses ellipsis) - If there are more than 2 authors in the thread, show the maximum of them. More recent senders have higher priority. - If it is finally necessary to hide any author, an ellipsis between first and next authors is added. """ # I will create a list of authors by parsing author_string. I use # deque to do popleft without performance penalties authors = deque() # If author list is too long, it uses only the first part of each # name (gmail style) short_names = len(authors_string) > maxlength for au in authors_string.split(", "): if short_names: author_as_list = au.split() if len(author_as_list) > 0: authors.append(author_as_list[0]) else: authors.append(au) # Author chain will contain the list of author strings to be # concatenated using commas for the final formatted author_string. authors_chain = deque() if len(authors) == 0: return '' # reserve space for first author first_au = shorten(authors.popleft(), maxlength) remaining_length = maxlength - len(first_au) # Tries to add an ellipsis if no space to show more than 1 author if authors and maxlength > 3 and remaining_length < 3: first_au = shorten(first_au, maxlength - 3) remaining_length += 3 # Tries to add as more authors as possible. It takes into account # that if any author will be hidden, and ellipsis should be added while authors and remaining_length >= 3: au = authors.pop() if len(au) > 1 and (remaining_length == 3 or (authors and remaining_length < 7)): authors_chain.appendleft('…') break else: if authors: # 5= ellipsis + 2 x comma and space used as separators au_string = shorten(au, remaining_length - 5) else: # 2 = comma and space used as separator au_string = shorten(au, remaining_length - 2) remaining_length -= len(au_string) + 2 authors_chain.appendleft(au_string) # Add the first author to the list and concatenate list authors_chain.appendleft(first_au) authorsstring = ', '.join(authors_chain) return authorsstring def pretty_datetime(d): """ translates :class:`datetime` `d` to a "sup-style" human readable string. >>> now = datetime.now() >>> now.strftime('%c') 'Sat 31 Mar 2012 14:47:26 ' >>> pretty_datetime(now) 'just now' >>> pretty_datetime(now - timedelta(minutes=1)) '1min ago' >>> pretty_datetime(now - timedelta(hours=5)) '5h ago' >>> pretty_datetime(now - timedelta(hours=12)) '02:54am' >>> pretty_datetime(now - timedelta(days=1)) 'yest 02pm' >>> pretty_datetime(now - timedelta(days=2)) 'Thu 02pm' >>> pretty_datetime(now - timedelta(days=7)) 'Mar 24' >>> pretty_datetime(now - timedelta(days=356)) 'Apr 2011' """ ampm = d.strftime('%p').lower() if len(ampm): hourfmt = '%I' + ampm hourminfmt = '%I:%M' + ampm else: hourfmt = '%Hh' hourminfmt = '%H:%M' now = datetime.now() today = now.date() if d.date() == today or d > now - timedelta(hours=6): delta = datetime.now() - d if delta.seconds < 60: string = 'just now' elif delta.seconds < 3600: string = '%dmin ago' % (delta.seconds // 60) elif delta.seconds < 6 * 3600: string = '%dh ago' % (delta.seconds // 3600) else: string = d.strftime(hourminfmt) elif d.date() == today - timedelta(1): string = d.strftime('yest ' + hourfmt) elif d.date() > today - timedelta(7): string = d.strftime('%a ' + hourfmt) elif d.year != today.year: string = d.strftime('%b %Y') else: string = d.strftime('%b %d') return string_decode(string, 'UTF-8') def call_cmd(cmdlist, stdin=None): """ get a shell commands output, error message and return value and immediately return. .. warning:: This returns with the first screen content for interactive commands. :param cmdlist: shellcommand to call, already splitted into a list accepted by :meth:`subprocess.Popen` :type cmdlist: list of str :param stdin: string to pipe to the process :type stdin: str, bytes, or None :return: triple of stdout, stderr, return value of the shell command :rtype: str, str, int """ termenc = urwid.util.detected_encoding if isinstance(stdin, str): stdin = stdin.encode(termenc) try: logging.debug("Calling %s" % cmdlist) proc = subprocess.Popen( cmdlist, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE if stdin is not None else None) except OSError as e: out = b'' err = e.strerror ret = e.errno else: out, err = proc.communicate(stdin) ret = proc.returncode out = string_decode(out, termenc) err = string_decode(err, termenc) return out, err, ret async def call_cmd_async(cmdlist, stdin=None): """Given a command, call that command asynchronously and return the output. This function only handles `OSError` when creating the subprocess, any other exceptions raised either durring subprocess creation or while exchanging data with the subprocess are the caller's responsibility to handle. If such an `OSError` is caught, then returncode will be set to 1, and the error value will be set to the str() value of the exception. :type cmdlist: list of str :param stdin: string to pipe to the process :type stdin: str :return: Tuple of stdout, stderr, returncode :rtype: tuple[str, str, int] """ termenc = urwid.util.detected_encoding cmdlist = [s.encode(termenc) for s in cmdlist] logging.debug('CMD = %s', cmdlist) try: proc = await asyncio.create_subprocess_exec( *cmdlist, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE if stdin else None) except OSError as e: return ('', str(e), 1) out, err = await proc.communicate(stdin.encode(termenc) if stdin else None) return (out.decode(termenc), err.decode(termenc), proc.returncode) def guess_mimetype(blob): """ uses file magic to determine the mime-type of the given data blob. :param blob: file content as read by file.read() :type blob: data :returns: mime-type, falls back to 'application/octet-stream' :rtype: str """ mimetype = 'application/octet-stream' # this is a bit of a hack to support different versions of python magic. # Hopefully at some point this will no longer be necessary # # the version with open() is the bindings shipped with the file source from # https://darwinsys.com/file/ - this is what is used by the python-magic # package on Debian/Ubuntu. However, it is not available on pypi/via pip. # # the version with from_buffer() is available at # https://github.com/ahupp/python-magic and directly installable via pip. # # for more detail see https://github.com/pazz/alot/pull/588 if hasattr(magic, 'open'): m = magic.open(magic.MAGIC_MIME_TYPE) m.load() magictype = m.buffer(blob) elif hasattr(magic, 'from_buffer'): # cf. issue #841 magictype = magic.from_buffer(blob, mime=True) or magictype else: raise Exception('Unknown magic API') # libmagic does not always return proper mimetype strings, cf. issue #459 if re.match(r'\w+\/\w+', magictype): mimetype = magictype return mimetype def guess_encoding(blob): """ uses file magic to determine the encoding of the given data blob. :param blob: file content as read by file.read() :type blob: data :returns: encoding :rtype: str """ # this is a bit of a hack to support different versions of python magic. # Hopefully at some point this will no longer be necessary # # the version with open() is the bindings shipped with the file source from # https://darwinsys.com/file/ - this is what is used by the python-magic # package on Debian/Ubuntu. However it is not available on pypi/via pip. # # the version with from_buffer() is available at # https://github.com/ahupp/python-magic and directly installable via pip. # # for more detail see https://github.com/pazz/alot/pull/588 if hasattr(magic, 'open'): m = magic.open(magic.MAGIC_MIME_ENCODING) m.load() return m.buffer(blob) elif hasattr(magic, 'from_buffer'): m = magic.Magic(mime_encoding=True) return m.from_buffer(blob) else: raise Exception('Unknown magic API') def try_decode(blob): """Guess the encoding of blob and try to decode it into a str. :param bytes blob: The bytes to decode :returns: the decoded blob :rtype: str """ assert isinstance(blob, bytes), 'cannot decode a str or non-bytes object' return blob.decode(guess_encoding(blob)) # TODO: make this work on blobs, not paths def mimewrap(path, filename=None, ctype=None): """Take the contents of the given path and wrap them into an email MIME part according to the content type. The content type is auto detected from the actual file contents and the file name if it is not given. :param path: the path to the file contents :type path: str :param filename: the file name to use in the generated MIME part :type filename: str or None :param ctype: the content type of the file contents in path :type ctype: str or None :returns: the message MIME part storing the data from path :rtype: subclasses of email.mime.base.MIMEBase """ with open(path, 'rb') as f: content = f.read() ctype = ctype or guess_mimetype(content) maintype, subtype = ctype.split('/', 1) if maintype == 'text': part = MIMEText(content.decode(guess_encoding(content), 'replace'), _subtype=subtype, _charset='utf-8') elif maintype == 'image': part = MIMEImage(content, _subtype=subtype) elif maintype == 'audio': part = MIMEAudio(content, _subtype=subtype) else: part = MIMEBase(maintype, subtype) part.set_payload(content) # Encode the payload using Base64 email.encoders.encode_base64(part) # Set the filename parameter if not filename: filename = os.path.basename(path) part.add_header('Content-Disposition', 'attachment', filename=filename) return part def shell_quote(text): """Escape the given text for passing it to the shell for interpretation. The resulting string will be parsed into one "word" (in the sense used in the shell documentation, see sh(1)) by the shell. :param text: the text to quote :type text: str :returns: the quoted text :rtype: str """ return "'%s'" % text.replace("'", """'"'"'""") def humanize_size(size): """Create a nice human readable representation of the given number (understood as bytes) using the "KiB" and "MiB" suffixes to indicate kibibytes and mebibytes. A kibibyte is defined as 1024 bytes (as opposed to a kilobyte which is 1000 bytes) and a mibibyte is 1024**2 bytes (as opposed to a megabyte which is 1000**2 bytes). :param size: the number to convert :type size: int :returns: the human readable representation of size :rtype: str """ for factor, format_string in ((1, '%i'), (1024, '%iKiB'), (1024 * 1024, '%.1fMiB')): if size / factor < 1024: return format_string % (size / factor) return format_string % (size / factor) def parse_mailcap_nametemplate(tmplate='%s'): """this returns a prefix and suffix to be used in the tempfile module for a given mailcap nametemplate string""" nt_list = tmplate.split('%s') template_prefix = '' template_suffix = '' if len(nt_list) == 2: template_suffix = nt_list[1] template_prefix = nt_list[0] else: template_suffix = tmplate return (template_prefix, template_suffix) def parse_mailto(mailto_str): """ Interpret mailto-string :param mailto_str: the string to interpret. Must conform to :rfc:2368. :type mailto_str: str :return: the header fields and the body found in the mailto link as a tuple of length two :rtype: tuple(dict(str->list(str)), str) """ if mailto_str.startswith('mailto:'): import urllib.parse to_str, parms_str = mailto_str[7:].partition('?')[::2] headers = {} body = '' to = urllib.parse.unquote(to_str) if to: headers['To'] = [to] for s in parms_str.split('&'): key, value = s.partition('=')[::2] key = key.capitalize() if key == 'Body': body = urllib.parse.unquote(value) elif value: headers[key] = [urllib.parse.unquote(value)] return (headers, body) else: return (None, None) def mailto_to_envelope(mailto_str): """ Interpret mailto-string into a :class:`alot.db.envelope.Envelope` """ from alot.db.envelope import Envelope headers, body = parse_mailto(mailto_str) return Envelope(bodytext=body, headers=headers) def RFC3156_canonicalize(text): """ Canonicalizes plain text (MIME-encoded usually) according to RFC3156. This function works as follows (in that order): 1. Convert all line endings to \\\\r\\\\n (DOS line endings). 2. Encode all occurrences of "From " at the beginning of a line to "From=20" in order to prevent other mail programs to replace this with "> From" (to avoid MBox conflicts) and thus invalidate the signature. :param text: text to canonicalize (already encoded as quoted-printable) :rtype: str """ text = re.sub("\r?\n", "\r\n", text) text = re.sub("^From ", "From=20", text, flags=re.MULTILINE) return text def get_xdg_env(env_name, fallback): """ Used for XDG_* env variables to return fallback if unset *or* empty """ env = os.environ.get(env_name) return env if env else fallback def get_notmuch_config_path(): """ Find the notmuch config file via env vars and default locations """ # This code is modeled after the description in nomtuch-config(1) # Case 1 is only applicable for the notmuch CLI # Case 2: the NOTMUCH_CONFIG env variable value = os.environ.get('NOTMUCH_CONFIG') if value is not None: return value # Case 3: new location in XDG config directory profile = os.environ.get('NOTMUCH_PROFILE', 'default') value = os.path.join(get_xdg_env('XDG_CONFIG_HOME', os.path.expanduser('~/.config')), 'notmuch', profile, 'config') if os.path.exists(value): return value # Case 4: traditional location in $HOME profile = os.environ.get('NOTMUCH_PROFILE', '') if profile: profile = '.' + profile return os.path.expanduser('~/.notmuch-config' + profile) alot-0.11/alot/settings/000077500000000000000000000000001466311112200151665ustar00rootroot00000000000000alot-0.11/alot/settings/__init__.py000066400000000000000000000000001466311112200172650ustar00rootroot00000000000000alot-0.11/alot/settings/const.py000066400000000000000000000003611466311112200166660ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from .manager import SettingsManager settings = SettingsManager() alot-0.11/alot/settings/errors.py000066400000000000000000000005401466311112200170530ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file class ConfigError(Exception): """could not parse user config""" pass class NoMatchingAccount(ConfigError): """No account matching requirements found.""" pass alot-0.11/alot/settings/manager.py000066400000000000000000000476231466311112200171660ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import importlib.util import itertools import logging import mailcap import os import re import email from configobj import ConfigObj, Section from ..account import SendmailAccount from ..addressbook.abook import AbookAddressBook from ..addressbook.external import ExternalAddressbook from ..helper import pretty_datetime, string_decode, get_xdg_env from ..utils import configobj as checks from .errors import ConfigError, NoMatchingAccount from .utils import read_config, read_notmuch_config from .utils import resolve_att from .theme import Theme DEFAULTSPATH = os.path.join(os.path.dirname(__file__), '..', 'defaults') DATA_DIRS = get_xdg_env('XDG_DATA_DIRS', '/usr/local/share:/usr/share').split(':') class SettingsManager: """Organizes user settings""" def __init__(self): self.hooks = None self._mailcaps = mailcap.getcaps() self._theme = None self._accounts = None self._accountmap = None self._notmuchconfig = None self._notmuchconfig_path = None self._config = ConfigObj() self._bindings = None def reload(self): """Reload notmuch and alot config files""" self.read_notmuch_config(self._notmuchconfig_path) self.read_config(self._config.filename) def read_notmuch_config(self, path): """ parse notmuch's config file :param path: path to notmuch's config file :type path: str """ self._notmuchconfig = read_notmuch_config(path) self._notmuchconfig_path = path # Set the path *after* succesful read. def _update_bindings(self, newbindings): assert isinstance(newbindings, Section) self._bindings = ConfigObj(os.path.join(DEFAULTSPATH, 'default.bindings')) self._bindings.merge(newbindings) def read_config(self, path): """ parse alot's config file :param path: path to alot's config file :type path: str """ spec = os.path.join(DEFAULTSPATH, 'alot.rc.spec') newconfig = read_config(path, spec, report_extra=True, checks={ 'mail_container': checks.mail_container, 'force_list': checks.force_list, 'align': checks.align_mode, 'attrtriple': checks.attr_triple, 'gpg_key_hint': checks.gpg_key}) self._config.merge(newconfig) self._config.walk(self._expand_config_values) # set up hooks module if requested hooks_path = self._config.get('hooksfile') if hooks_path: hooks_path = os.path.expanduser(hooks_path) logging.debug('hooks: loading from: %s', hooks_path) try: spec = importlib.util.spec_from_file_location('hooks', hooks_path) self.hooks = importlib.util.module_from_spec(spec) spec.loader.exec_module(self.hooks) except: logging.exception('unable to load hooks file:%s', hooks_path) else: logging.debug('hooks: hooksfile config option is unset') if 'bindings' in newconfig: self._update_bindings(newconfig['bindings']) tempdir = self._config.get('template_dir') logging.debug('template directory: `%s`' % tempdir) # themes themestring = newconfig['theme'] themes_dir = self._config.get('themes_dir') logging.debug('themes directory: `%s`' % themes_dir) # if config contains theme string use that data_dirs = [os.path.join(d, 'alot/themes') for d in DATA_DIRS] if themestring: # This is a python for/else loop # https://docs.python.org/3/reference/compound_stmts.html#for # # tl/dr; If the loop loads a theme it breaks. If it doesn't break, # then it raises a ConfigError. for dir_ in itertools.chain([themes_dir], data_dirs): theme_path = os.path.join(dir_, themestring) if not os.path.exists(os.path.expanduser(theme_path)): logging.warning('Theme `%s` does not exist.', theme_path) else: try: self._theme = Theme(theme_path) except ConfigError as e: raise ConfigError('Theme file `%s` failed ' 'validation:\n%s' % (theme_path, e)) else: break else: raise ConfigError('Could not find theme {}, see log for more ' 'information'.format(themestring)) # if still no theme is set, resort to default if self._theme is None: theme_path = os.path.join(DEFAULTSPATH, 'default.theme') self._theme = Theme(theme_path) self._accounts = self._parse_accounts(self._config) self._accountmap = self._account_table(self._accounts) @staticmethod def _expand_config_values(section, key): """ Walker function for ConfigObj.walk Applies expand_environment_and_home to all configuration values that are strings (or strings that are elements of tuples/lists) :param section: as passed by ConfigObj.walk :param key: as passed by ConfigObj.walk """ def expand_environment_and_home(value): """ Expands environment variables and the home directory (~). $FOO and ${FOO}-style environment variables are expanded, if they exist. If they do not exist, they are left unchanged. The exception are the following $XDG_* variables that are expanded to fallback values, if they are empty or not set: $XDG_CONFIG_HOME $XDG_CACHE_HOME :param value: configuration string :type value: str """ xdg_vars = {'XDG_CONFIG_HOME': '~/.config', 'XDG_CACHE_HOME': '~/.cache'} for xdg_name, fallback in xdg_vars.items(): if xdg_name in value: xdg_value = get_xdg_env(xdg_name, fallback) value = value.replace('$%s' % xdg_name, xdg_value)\ .replace('${%s}' % xdg_name, xdg_value) return os.path.expanduser(os.path.expandvars(value)) value = section[key] if isinstance(value, str): section[key] = expand_environment_and_home(value) elif isinstance(value, (list, tuple)): new = list() for item in value: if isinstance(item, str): new.append(expand_environment_and_home(item)) else: new.append(item) section[key] = new @staticmethod def _parse_accounts(config): """ read accounts information from config :param config: valit alot config :type config: `configobj.ConfigObj` :returns: list of accounts """ accounts = [] if 'accounts' in config: for acc in config['accounts'].sections: accsec = config['accounts'][acc] args = dict(config['accounts'][acc].items()) # create abook for this account abook = accsec['abook'] logging.debug('abook defined: %s', abook) if abook['type'] == 'shellcommand': cmd = abook['command'] regexp = abook['regexp'] if cmd is not None and regexp is not None: ef = abook['shellcommand_external_filtering'] args['abook'] = ExternalAddressbook( cmd, regexp, external_filtering=ef) else: msg = 'underspecified abook of type \'shellcommand\':' msg += '\ncommand: %s\nregexp:%s' % (cmd, regexp) raise ConfigError(msg) elif abook['type'] == 'abook': contacts_path = abook['abook_contacts_file'] args['abook'] = AbookAddressBook( contacts_path, ignorecase=abook['ignorecase']) else: del args['abook'] cmd = args['sendmail_command'] del args['sendmail_command'] newacc = SendmailAccount(cmd, **args) accounts.append(newacc) return accounts @staticmethod def _account_table(accounts): """ creates a lookup table (emailaddress -> account) for a given list of accounts :param accounts: list of accounts :type accounts: list of `alot.account.Account` :returns: hashtable :rvalue: dict (str -> `alot.account.Account`) """ accountmap = {} for acc in accounts: accountmap[acc.address] = acc for alias in acc.aliases: accountmap[alias] = acc return accountmap def get(self, key, fallback=None): """ look up global config values from alot's config :param key: key to look up :type key: str :param fallback: fallback returned if key is not present :type fallback: str :returns: config value with type as specified in the spec-file """ value = None if key in self._config: value = self._config[key] if isinstance(value, Section): value = None if value is None: value = fallback return value def set(self, key, value): """ setter for global config values :param key: config option identifies :type key: str :param value: option to set :type value: depends on the specfile :file:`alot.rc.spec` """ self._config[key] = value def get_notmuch_setting(self, section, key, fallback=None): """ look up config values from notmuch's config :param section: key is in :type section: str :param key: key to look up :type key: str :param fallback: fallback returned if key is not present :type fallback: str :returns: the config value :rtype: str """ value = None if section in self._notmuchconfig: if key in self._notmuchconfig[section]: value = self._notmuchconfig[section][key] if value is None: value = fallback return value def get_theming_attribute(self, mode, name, part=None): """ looks up theming attribute :param mode: ui-mode (e.g. `search`,`thread`...) :type mode: str :param name: identifier of the atttribute :type name: str :rtype: urwid.AttrSpec """ colours = int(self._config.get('colourmode')) return self._theme.get_attribute(colours, mode, name, part) def get_threadline_theming(self, thread): """ looks up theming info a threadline displaying a given thread. This wraps around :meth:`~alot.settings.theme.Theme.get_threadline_theming`, filling in the current colour mode. :param thread: thread to theme :type thread: alot.db.thread.Thread """ colours = int(self._config.get('colourmode')) return self._theme.get_threadline_theming(thread, colours) def get_tagstring_representation(self, tag, onebelow_normal=None, onebelow_focus=None): """ looks up user's preferred way to represent a given tagstring. :param tag: tagstring :type tag: str :param onebelow_normal: attribute that shines through if unfocussed :type onebelow_normal: urwid.AttrSpec :param onebelow_focus: attribute that shines through if focussed :type onebelow_focus: urwid.AttrSpec If `onebelow_normal` or `onebelow_focus` is given these attributes will be used as fallbacks for fg/bg values '' and 'default'. This returns a dictionary mapping :normal: to :class:`urwid.AttrSpec` used if unfocussed :focussed: to :class:`urwid.AttrSpec` used if focussed :translated: to an alternative string representation """ colourmode = int(self._config.get('colourmode')) theme = self._theme cfg = self._config colours = [1, 16, 256] def colourpick(triple): """ pick attribute from triple (mono,16c,256c) according to current colourmode""" if triple is None: return None return triple[colours.index(colourmode)] # global default attributes for tagstrings. # These could contain values '' and 'default' which we interpret as # "use the values from the widget below" default_normal = theme.get_attribute(colourmode, 'global', 'tag') default_focus = theme.get_attribute(colourmode, 'global', 'tag_focus') # local defaults for tagstring attributes. depend on next lower widget fallback_normal = resolve_att(onebelow_normal, default_normal) fallback_focus = resolve_att(onebelow_focus, default_focus) for sec in cfg['tags'].sections: if re.match('^{}$'.format(sec), tag): normal = resolve_att(colourpick(cfg['tags'][sec]['normal']), fallback_normal) focus = resolve_att(colourpick(cfg['tags'][sec]['focus']), fallback_focus) translated = cfg['tags'][sec]['translated'] translated = string_decode(translated, 'UTF-8') if translated is None: translated = tag translation = cfg['tags'][sec]['translation'] if translation: translated = re.sub(translation[0], translation[1], tag) break else: normal = fallback_normal focus = fallback_focus translated = tag return {'normal': normal, 'focussed': focus, 'translated': translated} def get_hook(self, key): """return hook (`callable`) identified by `key`""" if self.hooks: return getattr(self.hooks, key, None) return None def get_mapped_input_keysequences(self, mode='global', prefix=''): # get all bindings in this mode globalmaps, modemaps = self.get_keybindings(mode) candidates = list(globalmaps.keys()) + list(modemaps.keys()) if prefix is not None: prefixes = prefix + ' ' cand = [c for c in candidates if c.startswith(prefixes)] if prefix in candidates: candidates = cand + [prefix] else: candidates = cand return candidates def get_keybindings(self, mode): """look up keybindings from `MODE-maps` sections :param mode: mode identifier :type mode: str :returns: dictionaries of key-cmd for global and specific mode :rtype: 2-tuple of dicts """ globalmaps, modemaps = {}, {} bindings = self._bindings # get bindings for mode `mode` # retain empty assignations to silence corresponding global mappings if mode in bindings.sections: for key in bindings[mode].scalars: value = bindings[mode][key] if isinstance(value, list): value = ','.join(value) modemaps[key] = value # get global bindings # ignore the ones already mapped in mode bindings for key in bindings.scalars: if key not in modemaps: value = bindings[key] if isinstance(value, list): value = ','.join(value) if value and value != '': globalmaps[key] = value # get rid of empty commands left in mode bindings for k, v in list(modemaps.items()): if not v: del modemaps[k] return globalmaps, modemaps def get_keybinding(self, mode, key): """look up keybinding from `MODE-maps` sections :param mode: mode identifier :type mode: str :param key: urwid-style key identifier :type key: str :returns: a command line to be applied upon keypress :rtype: str """ cmdline = None bindings = self._bindings if key in bindings.scalars: cmdline = bindings[key] if mode in bindings.sections: if key in bindings[mode].scalars: value = bindings[mode][key] if value: cmdline = value else: # to be sure it isn't mapped globally cmdline = None # Workaround for ConfigObj misbehaviour. cf issue #500 # this ensures that we get at least strings only as commandlines if isinstance(cmdline, list): cmdline = ','.join(cmdline) return cmdline def get_accounts(self): """ returns known accounts :rtype: list of :class:`Account` """ return self._accounts def account_matching_address(self, address, return_default=False): """returns :class:`Account` for a given email address (str) :param str address: address to look up. A realname part will be ignored. :param bool return_default: If True and no address can be found, then the default account wil be returned. :rtype: :class:`Account` :raises ~alot.settings.errors.NoMatchingAccount: If no account can be found. This includes if return_default is True and there are no accounts defined. """ _, address = email.utils.parseaddr(address) for account in self.get_accounts(): if account.matches_address(address): return account if return_default: try: return self.get_accounts()[0] except IndexError: # Fall through pass raise NoMatchingAccount def get_main_addresses(self): """returns addresses of known accounts without its aliases""" return [a.address for a in self._accounts] def get_addressbooks(self, order=None, append_remaining=True): """returns list of all defined :class:`AddressBook` objects""" order = order or [] abooks = [] for a in order: if a: if a.abook: abooks.append(a.abook) if append_remaining: for a in self._accounts: if a.abook and a.abook not in abooks: abooks.append(a.abook) return abooks def mailcap_find_match(self, *args, **kwargs): """ Propagates :func:`mailcap.find_match` but caches the mailcap (first argument) """ return mailcap.findmatch(self._mailcaps, *args, **kwargs) def represent_datetime(self, d): """ turns a given datetime obj into a string representation. This will: 1) look if a fixed 'timestamp_format' is given in the config 2) check if a 'timestamp_format' hook is defined 3) use :func:`~alot.helper.pretty_datetime` as fallback """ fixed_format = self.get('timestamp_format') if fixed_format: rep = string_decode(d.strftime(fixed_format), 'UTF-8') else: format_hook = self.get_hook('timestamp_format') if format_hook: rep = string_decode(format_hook(d), 'UTF-8') else: rep = pretty_datetime(d) return rep alot-0.11/alot/settings/theme.py000066400000000000000000000123461466311112200166500ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import os from ..utils import configobj as checks from .utils import read_config from .errors import ConfigError DEFAULTSPATH = os.path.join(os.path.dirname(__file__), '..', 'defaults') DUMMYDEFAULT = ('default',) * 6 class Theme: """Colour theme""" def __init__(self, path): """ :param path: path to theme file :type path: str :raises: :class:`~alot.settings.errors.ConfigError` """ self._spec = os.path.join(DEFAULTSPATH, 'theme.spec') self._config = read_config(path, self._spec, report_extra=True, checks={'align': checks.align_mode, 'widthtuple': checks.width_tuple, 'force_list': checks.force_list, 'attrtriple': checks.attr_triple}) self._colours = [1, 16, 256] # make sure every entry in 'order' lists have their own subsections threadline = self._config['search']['threadline'] for sec in self._config['search']: if sec.startswith('threadline'): tline = self._config['search'][sec] if tline['parts'] is not None: listed = set(tline['parts']) here = set(tline.sections) indefault = set(threadline.sections) diff = listed.difference(here.union(indefault)) if diff: msg = 'missing threadline parts: %s' % ', '.join(diff) raise ConfigError(msg) def get_attribute(self, colourmode, mode, name, part=None): """ returns requested attribute :param mode: ui-mode (e.g. `search`,`thread`...) :type mode: str :param name: of the atttribute :type name: str :param colourmode: colour mode; in [1, 16, 256] :type colourmode: int :rtype: urwid.AttrSpec """ thmble = self._config[mode][name] if part is not None: thmble = thmble[part] thmble = thmble or DUMMYDEFAULT return thmble[self._colours.index(colourmode)] def get_threadline_theming(self, thread, colourmode): """ look up how to display a Threadline widget in search mode for a given thread. :param thread: Thread to theme Threadline for :type thread: alot.db.thread.Thread :param colourmode: colourmode to use, one of 1,16,256. :type colourmode: int This will return a dict mapping :normal: to `urwid.AttrSpec`, :focus: to `urwid.AttrSpec`, :parts: to a list of strings indentifying subwidgets to be displayed in this order. Moreover, for every part listed this will map 'part' to a dict mapping :normal: to `urwid.AttrSpec`, :focus: to `urwid.AttrSpec`, :width: to a tuple indicating the width of the subpart. This is either `('fit', min, max)` to force the widget to be at least `min` and at most `max` characters wide, or `('weight', n)` which makes it share remaining space with other 'weight' parts. :alignment: where to place the content if shorter than the widget. This is either 'right', 'left' or 'center'. """ def pickcolour(triple): return triple[self._colours.index(colourmode)] def matches(sec, thread): if sec.get('tagged_with') is not None: if not set(sec['tagged_with']).issubset(thread.get_tags()): return False if sec.get('query') is not None: if not thread.matches(sec['query']): return False return True default = self._config['search']['threadline'] match = default candidates = self._config['search'].sections for candidatename in candidates: candidate = self._config['search'][candidatename] if (candidatename.startswith('threadline') and (not candidatename == 'threadline') and matches(candidate, thread)): match = candidate break # fill in values res = {} res['normal'] = pickcolour(match.get('normal') or default['normal']) res['focus'] = pickcolour(match.get('focus') or default['focus']) res['parts'] = match.get('parts') or default['parts'] for part in res['parts']: defaultsec = default.get(part) partsec = match.get(part) or {} def fill(key, fallback=None): pvalue = partsec.get(key) or defaultsec.get(key) return pvalue or fallback res[part] = {} res[part]['width'] = fill('width', ('fit', 0, 0)) res[part]['alignment'] = fill('alignment', 'right') res[part]['normal'] = pickcolour(fill('normal')) res[part]['focus'] = pickcolour(fill('focus')) return res alot-0.11/alot/settings/utils.py000066400000000000000000000124051466311112200167020ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import logging from configobj import (ConfigObj, ConfigObjError, flatten_errors, get_extra_values) from validate import Validator from urwid import AttrSpec from .errors import ConfigError from ..helper import call_cmd def read_config(configpath=None, specpath=None, checks=None, report_extra=False): """ get a (validated) config object for given config file path. :param configpath: path to config-file or a list of lines as its content :type configpath: str or list(str) :param specpath: path to spec-file :type specpath: str :param checks: custom checks to use for validator. see `validate docs `_ :type checks: dict str->callable, :param report_extra: log if a setting is not present in the spec file :type report_extra: boolean :raises: :class:`~alot.settings.errors.ConfigError` :rtype: `configobj.ConfigObj` """ checks = checks or {} try: config = ConfigObj(infile=configpath, configspec=specpath, file_error=True, encoding='UTF8') except ConfigObjError as e: msg = 'Error when parsing `%s`:\n%s' % (configpath, e) logging.error(msg) raise ConfigError(msg) except IOError: raise ConfigError('Could not read %s and/or %s' % (configpath, specpath)) except UnboundLocalError: # this works around a bug in configobj msg = '%s is malformed. Check for sections without parents..' raise ConfigError(msg % configpath) if specpath: validator = Validator() validator.functions.update(checks) try: results = config.validate(validator, preserve_errors=True) except ConfigObjError as e: raise ConfigError(str(e)) if results is not True: error_msg = '' for (section_list, key, res) in flatten_errors(config, results): if key is not None: if res is False: msg = 'key "%s" in section "%s" is missing.' msg = msg % (key, ', '.join(section_list)) else: msg = 'key "%s" in section "%s" failed validation: %s' msg = msg % (key, ', '.join(section_list), res) else: msg = 'section "%s" is missing' % '.'.join(section_list) error_msg += msg + '\n' raise ConfigError(error_msg) extra_values = get_extra_values(config) if report_extra else None if extra_values: msg = ['Unknown values were found in `%s`. Please check for ' 'typos if a specified setting does not seem to work:' % configpath] for sections, val in extra_values: if sections: msg.append('%s: %s' % ('->'.join(sections), val)) else: msg.append(str(val)) logging.info('\n'.join(msg)) return config def read_notmuch_config(path): """ Read notmuch configuration. This function calls the command "notmuch --config {path} config list" and parses its output into a ``config`` dictionary, which is then returned. The configuration value for a key under a section can be accessed with ``config[section][key]``. The returned value is a dict ``config`` with some values converted to special python types. These are the values that alot is known to use. All other values in the returned dict are just strings. :param path: path to the configuration file, which is passed as argument to the --config option of notmuch. :type path: str :raises: :class:`~alot.settings.errors.ConfigError` :rtype: dict """ cmd = ['notmuch', '--config', path, 'config', 'list'] out, err, code = call_cmd(cmd) if code != 0: msg = f'failed to read notmuch config with command {cmd} (exit error: {code}):\n{err}' logging.error(msg) raise ConfigError(msg) parsed = ConfigObj(infile=out.splitlines(), interpolation=False, list_values=False) config = {} try: for dotted_key, value in parsed.items(): section, key = dotted_key.split(".", maxsplit=1) if section == "maildir" and key == "synchronize_flags": value = parsed.as_bool(dotted_key) if section == "search" and key == "exclude_tags": if value: value = [t for t in value.split(';') if t] config.setdefault(section, {})[key] = value except ValueError as e: raise ConfigError(f"Bad value in notmuch config file: {e}") return config def resolve_att(a, fallback): """ replace '' and 'default' by fallback values """ if a is None: return fallback if a.background in ['default', '']: bg = fallback.background else: bg = a.background if a.foreground in ['default', '']: fg = fallback.foreground else: fg = a.foreground return AttrSpec(fg, bg) alot-0.11/alot/ui.py000066400000000000000000000754551466311112200143350ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import logging import os import signal import codecs import contextlib import asyncio import traceback import urwid from .settings.const import settings from .buffers import BufferlistBuffer from .buffers import SearchBuffer from .commands import globals from .commands import commandfactory from .commands import CommandCanceled, SequenceCanceled from .commands import CommandParseError from .helper import split_commandline from .helper import string_decode from .helper import get_xdg_env from .widgets.globals import CompleteEdit from .widgets.globals import ChoiceWidget async def periodic(callable_, period, *args, **kwargs): while True: try: t = callable_(*args, **kwargs) if asyncio.iscoroutine(t): await t except Exception as e: logging.error('error in loop hook %s', str(e)) await asyncio.sleep(period) class UI: """ This class integrates all components of alot and offers methods for user interaction like :meth:`prompt`, :meth:`notify` etc. It handles the urwid widget tree and mainloop (we use asyncio) and is responsible for opening, closing and focussing buffers. """ def __init__(self, dbman, initialcmdline): """ :param dbman: :class:`~alot.db.DBManager` :param initialcmdline: commandline applied after setting up interface :type initialcmdline: str :param colourmode: determines which theme to chose :type colourmode: int in [1,16,256] """ self.dbman = dbman """Database Manager (:class:`~alot.db.manager.DBManager`)""" self.buffers = [] """list of active buffers""" self.current_buffer = None """points to currently active :class:`~alot.buffers.Buffer`""" self.db_was_locked = False """flag used to prevent multiple 'index locked' notifications""" self.mode = 'global' """interface mode identifier - type of current buffer""" self.commandprompthistory = [] """history of the command line prompt""" self.senderhistory = [] """history of the sender prompt""" self.recipienthistory = [] """history of the recipients prompt""" self.input_queue = [] """stores partial keyboard input""" self.last_commandline = None """saves the last executed commandline""" # define empty notification pile self._notificationbar = None # should we show a status bar? self._show_statusbar = settings.get('show_statusbar') # pass keypresses to the root widget and never interpret bindings self._passall = False # indicates "input lock": only focus move commands are interpreted self._locked = False self._unlock_callback = None # will be called after input lock ended self._unlock_key = None # key that ends input lock # alarm handle for callback that clears input queue (to cancel alarm) self._alarm = None # force urwid to pass key events as unicode, independent of LANG urwid.set_encoding('utf-8') # create root widget global_att = settings.get_theming_attribute('global', 'body') mainframe = urwid.Frame(urwid.SolidFill()) self.root_widget = urwid.AttrMap(mainframe, global_att) signal.signal(signal.SIGINT, self._handle_signal) signal.signal(signal.SIGUSR1, self._handle_signal) # load histories self._cache = os.path.join( get_xdg_env('XDG_CACHE_HOME', os.path.expanduser('~/.cache')), 'alot', 'history') self._cmd_hist_file = os.path.join(self._cache, 'commands') self._sender_hist_file = os.path.join(self._cache, 'senders') self._recipients_hist_file = os.path.join(self._cache, 'recipients') size = settings.get('history_size') self.commandprompthistory = self._load_history_from_file( self._cmd_hist_file, size=size) self.senderhistory = self._load_history_from_file( self._sender_hist_file, size=size) self.recipienthistory = self._load_history_from_file( self._recipients_hist_file, size=size) # set up main loop self.mainloop = urwid.MainLoop( self.root_widget, handle_mouse=settings.get('handle_mouse'), event_loop=urwid.TwistedEventLoop(), unhandled_input=self._unhandled_input, input_filter=self._input_filter) loop = asyncio.get_event_loop() # Create a task for the periodic hook loop_hook = settings.get_hook('loop_hook') if loop_hook: # In Python 3.7 a nice aliase `asyncio.create_task` was added loop.create_task( periodic( loop_hook, settings.get('periodic_hook_frequency'), ui=self)) # set up colours colourmode = int(settings.get('colourmode')) logging.info('setup gui in %d colours', colourmode) self.mainloop.screen.set_terminal_properties(colors=colourmode) # clear the screen before the initial frame self.mainloop.screen.clear() logging.debug('fire first command') loop.create_task(self.apply_commandline(initialcmdline)) # start urwids mainloop self.mainloop.run() def _error_handler(self, exception): if isinstance(exception, CommandParseError): self.notify(str(exception), priority='error') elif isinstance(exception, CommandCanceled): self.notify("operation cancelled", priority='error') elif isinstance(exception, SequenceCanceled): # This exception needs to trickle up to apply_commandline, # then be handled explicitly. raise exception else: logging.error(traceback.format_exc()) msg = "{}\n(check the log for details)".format(exception) self.notify(msg, priority='error') def _input_filter(self, keys, raw): """ handles keypresses. This function gets triggered directly by class:`urwid.MainLoop` upon user input and is supposed to pass on its `keys` parameter to let the root widget handle keys. We intercept the input here to trigger custom commands as defined in our keybindings. """ logging.debug("Got key (%s, %s)", keys, raw) # work around: escape triggers this twice, with keys = raw = [] # the first time.. if not keys: return # let widgets handle input if key is virtual window resize keypress # or we are in "passall" mode elif 'window resize' in keys or self._passall: return keys # end "lockdown" mode if the right key was pressed elif self._locked and keys[0] == self._unlock_key: self._locked = False self.mainloop.widget = self.root_widget if callable(self._unlock_callback): self._unlock_callback() # otherwise interpret keybinding else: def clear(*_): """Callback that resets the input queue.""" if self._alarm is not None: self.mainloop.remove_alarm(self._alarm) self.input_queue = [] async def _apply_fire(cmdline): try: await self.apply_commandline(cmdline) except CommandParseError as e: self.notify(str(e), priority='error') def fire(_, cmdline): clear() logging.debug("cmdline: '%s'", cmdline) if not self._locked: loop = asyncio.get_event_loop() loop.create_task(_apply_fire(cmdline)) # move keys are always passed elif cmdline in ['move up', 'move down', 'move page up', 'move page down']: return [cmdline[5:]] key = keys[0] if key and 'mouse' in key[0]: key = key[0] + ' %i' % key[1] self.input_queue.append(key) keyseq = ' '.join(self.input_queue) candidates = settings.get_mapped_input_keysequences(self.mode, prefix=keyseq) if keyseq in candidates: # case: current input queue is a mapped keysequence # get binding and interpret it if non-null cmdline = settings.get_keybinding(self.mode, keyseq) if cmdline: if len(candidates) > 1: timeout = float(settings.get('input_timeout')) if self._alarm is not None: self.mainloop.remove_alarm(self._alarm) self._alarm = self.mainloop.set_alarm_in( timeout, fire, cmdline) else: return fire(self.mainloop, cmdline) elif not candidates: # case: no sequence with prefix keyseq is mapped # just clear the input queue clear() else: # case: some sequences with proper prefix keyseq is mapped timeout = float(settings.get('input_timeout')) if self._alarm is not None: self.mainloop.remove_alarm(self._alarm) self._alarm = self.mainloop.set_alarm_in(timeout, clear) # update statusbar self.update() async def apply_commandline(self, cmdline): """ interprets a command line string i.e., splits it into separate command strings, instanciates :class:`Commands ` accordingly and applies then in sequence. :param cmdline: command line to interpret :type cmdline: str """ # remove initial spaces cmdline = cmdline.lstrip() # we pass Commands one by one to `self.apply_command`. # To properly call them in sequence, even if they trigger asyncronous # code (return Deferreds), these applications happen in individual # callback functions which are then used as callback chain to some # trivial Deferred that immediately calls its first callback. This way, # one callback may return a Deferred and thus postpone the application # of the next callback (and thus Command-application) def apply_this_command(cmdstring): logging.debug('%s command string: "%s"', self.mode, str(cmdstring)) # translate cmdstring into :class:`Command` cmd = commandfactory(cmdstring, self.mode) # store cmdline for use with 'repeat' command if cmd.repeatable: self.last_commandline = cmdline return self.apply_command(cmd) try: for c in split_commandline(cmdline): await apply_this_command(c) except Exception as e: if isinstance(e, SequenceCanceled): self.notify("sequence of operations cancelled", priority='error') else: self._error_handler(e) @staticmethod def _unhandled_input(key): """ Called by :class:`urwid.MainLoop` if a keypress was passed to the root widget by `self._input_filter` but is not handled in any widget. We keep it for debugging purposes. """ logging.debug('unhandled input: %s', key) def show_as_root_until_keypress(self, w, key, afterwards=None): """ Replaces root widget by given :class:`urwid.Widget` and makes the UI ignore all further commands apart from cursor movement. If later on `key` is pressed, the old root widget is reset, callable `afterwards` is called and normal behaviour is resumed. """ self.mainloop.widget = w self._unlock_key = key self._unlock_callback = afterwards self._locked = True def prompt(self, prefix, text='', completer=None, tab=0, history=None): """ prompt for text input. This returns a :class:`asyncio.Future`, which will have a string value :param prefix: text to print before the input field :type prefix: str :param text: initial content of the input field :type text: str :param completer: completion object to use :type completer: :meth:`alot.completion.Completer` :param tab: number of tabs to press initially (to select completion results) :type tab: int :param history: history to be used for up/down keys :type history: list of str :rtype: asyncio.Future """ history = history or [] fut = asyncio.get_event_loop().create_future() oldroot = self.mainloop.widget def select_or_cancel(text): """Restore the main screen and invoce the callback (delayed return) with the given text.""" self.mainloop.widget = oldroot self._passall = False fut.set_result(text) def cerror(e): logging.error(e) self.notify('completion error: %s' % str(e), priority='error') self.update() prefix = prefix + settings.get('prompt_suffix') # set up widgets leftpart = urwid.Text(prefix, align='left') editpart = CompleteEdit(completer, on_exit=select_or_cancel, edit_text=text, history=history, on_error=cerror) for _ in range(tab): # hit some tabs editpart.keypress((0,), 'tab') # build promptwidget both = urwid.Columns( [ ('fixed', len(prefix), leftpart), ('weight', 1, editpart), ]) att = settings.get_theming_attribute('global', 'prompt') both = urwid.AttrMap(both, att) # put promptwidget as overlay on main widget overlay = urwid.Overlay(both, oldroot, ('fixed left', 0), ('fixed right', 0), ('fixed bottom', 1), None) self.mainloop.widget = overlay self._passall = True return fut @staticmethod def exit(): """ shuts down user interface without cleaning up. Use a :class:`alot.commands.globals.ExitCommand` for a clean shutdown. """ try: loop = asyncio.get_event_loop() loop.stop() except Exception as e: logging.error('Could not stop loop: %s\nShutting down anyway..', str(e)) @contextlib.contextmanager def paused(self): """ context manager that pauses the UI to allow running external commands. If an exception occurs, the UI will be started before the exception is re-raised. """ self.mainloop.stop() try: yield finally: self.mainloop.start() # make sure urwid renders its canvas at the correct size self.mainloop.screen_size = None self.mainloop.draw_screen() def buffer_open(self, buf): """register and focus new :class:`~alot.buffers.Buffer`.""" # call pre_buffer_open hook prehook = settings.get_hook('pre_buffer_open') if prehook is not None: prehook(ui=self, dbm=self.dbman, buf=buf) if self.current_buffer is not None: offset = settings.get('bufferclose_focus_offset') * -1 currentindex = self.buffers.index(self.current_buffer) self.buffers.insert(currentindex + offset, buf) else: self.buffers.append(buf) self.buffer_focus(buf) # call post_buffer_open hook posthook = settings.get_hook('post_buffer_open') if posthook is not None: posthook(ui=self, dbm=self.dbman, buf=buf) def buffer_close(self, buf, redraw=True): """ closes given :class:`~alot.buffers.Buffer`. This it removes it from the bufferlist and calls its cleanup() method. """ # call pre_buffer_close hook prehook = settings.get_hook('pre_buffer_close') if prehook is not None: prehook(ui=self, dbm=self.dbman, buf=buf) buffers = self.buffers success = False if buf not in buffers: logging.error('tried to close unknown buffer: %s. \n\ni have:%s', buf, self.buffers) elif self.current_buffer == buf: logging.info('closing current buffer %s', buf) index = buffers.index(buf) buffers.remove(buf) offset = settings.get('bufferclose_focus_offset') nextbuffer = buffers[(index + offset) % len(buffers)] self.buffer_focus(nextbuffer, redraw) buf.cleanup() success = True else: buffers.remove(buf) buf.cleanup() success = True # call post_buffer_closed hook posthook = settings.get_hook('post_buffer_closed') if posthook is not None: posthook(ui=self, dbm=self.dbman, buf=buf, success=success) def buffer_focus(self, buf, redraw=True): """focus given :class:`~alot.buffers.Buffer`.""" # call pre_buffer_focus hook prehook = settings.get_hook('pre_buffer_focus') if prehook is not None: prehook(ui=self, dbm=self.dbman, buf=buf) success = False if buf not in self.buffers: logging.error('tried to focus unknown buffer') else: if self.current_buffer != buf: self.current_buffer = buf self.mode = buf.modename if isinstance(self.current_buffer, BufferlistBuffer): self.current_buffer.rebuild() self.update() success = True # call post_buffer_focus hook posthook = settings.get_hook('post_buffer_focus') if posthook is not None: posthook(ui=self, dbm=self.dbman, buf=buf, success=success) def get_deep_focus(self, startfrom=None): """return the bottom most focussed widget of the widget tree""" if not startfrom: startfrom = self.current_buffer if 'get_focus' in dir(startfrom): focus = startfrom.get_focus() if isinstance(focus, tuple): focus = focus[0] if isinstance(focus, urwid.Widget): return self.get_deep_focus(startfrom=focus) return startfrom def get_buffers_of_type(self, t): """ returns currently open buffers for a given subclass of :class:`~alot.buffers.Buffer`. :param t: Buffer class :type t: alot.buffers.Buffer :rtype: list """ return [x for x in self.buffers if isinstance(x, t)] def clear_notify(self, messages): """ Clears notification popups. Call this to ged rid of messages that don't time out. :param messages: The popups to remove. This should be exactly what :meth:`notify` returned when creating the popup """ newpile = self._notificationbar.widget_list for l in messages: if l in newpile: newpile.remove(l) if newpile: self._notificationbar = urwid.Pile(newpile) else: self._notificationbar = None self.update() def choice(self, message, choices=None, select=None, cancel=None, msg_position='above', choices_to_return=None): """ prompt user to make a choice. :param message: string to display before list of choices :type message: unicode :param choices: dict of possible choices :type choices: dict: keymap->choice (both str) :param choices_to_return: dict of possible choices to return for the choices of the choices of paramter :type choices: dict: keymap->choice key is str and value is any obj) :param select: choice to return if enter/return is hit. Ignored if set to `None`. :type select: str :param cancel: choice to return if escape is hit. Ignored if set to `None`. :type cancel: str :param msg_position: determines if `message` is above or left of the prompt. Must be `above` or `left`. :type msg_position: str :rtype: asyncio.Future """ choices = choices or {'y': 'yes', 'n': 'no'} assert select is None or select in choices.values() assert cancel is None or cancel in choices.values() assert msg_position in ['left', 'above'] fut = asyncio.get_event_loop().create_future() # Create a returned future oldroot = self.mainloop.widget def select_or_cancel(text): """Restore the main screen and invoce the callback (delayed return) with the given text.""" self.mainloop.widget = oldroot self._passall = False fut.set_result(text) # set up widgets msgpart = urwid.Text(message) choicespart = ChoiceWidget(choices, choices_to_return=choices_to_return, callback=select_or_cancel, select=select, cancel=cancel) # build widget if msg_position == 'left': both = urwid.Columns( [ ('fixed', len(message), msgpart), ('weight', 1, choicespart), ], dividechars=1) else: # above both = urwid.Pile([msgpart, choicespart]) att = settings.get_theming_attribute('global', 'prompt') both = urwid.AttrMap(both, att, att) # put promptwidget as overlay on main widget overlay = urwid.Overlay(both, oldroot, ('fixed left', 0), ('fixed right', 0), ('fixed bottom', 1), None) self.mainloop.widget = overlay self._passall = True return fut def notify(self, message, priority='normal', timeout=0, block=False): """ opens notification popup. :param message: message to print :type message: str :param priority: priority string, used to format the popup: currently, 'normal' and 'error' are defined. If you use 'X' here, the attribute 'global_notify_X' is used to format the popup. :type priority: str :param timeout: seconds until message disappears. Defaults to the value of 'notify_timeout' in the general config section. A negative value means never time out. :type timeout: int :param block: this notification blocks until a keypress is made :type block: bool :returns: an urwid widget (this notification) that can be handed to :meth:`clear_notify` for removal """ def build_line(msg, prio): cols = urwid.Columns([urwid.Text(msg)]) att = settings.get_theming_attribute('global', 'notify_' + prio) return urwid.AttrMap(cols, att) msgs = [build_line(message, priority)] if not self._notificationbar: self._notificationbar = urwid.Pile(msgs) else: newpile = self._notificationbar.widget_list + msgs self._notificationbar = urwid.Pile(newpile) self.update() def clear(*_): self.clear_notify(msgs) if block: # put "cancel to continue" widget as overlay on main widget txt = build_line('(escape continues)', priority) overlay = urwid.Overlay(txt, self.root_widget, ('fixed left', 0), ('fixed right', 0), ('fixed bottom', 0), None) self.show_as_root_until_keypress(overlay, 'esc', afterwards=clear) else: if timeout >= 0: if timeout == 0: timeout = settings.get('notify_timeout') self.mainloop.set_alarm_in(timeout, clear) return msgs[0] def update(self, redraw=True): """redraw interface""" # get the main urwid.Frame widget mainframe = self.root_widget.original_widget # body if self.current_buffer: mainframe.set_body(self.current_buffer) # footer lines = [] if self._notificationbar: # .get_text()[0] != ' ': lines.append(self._notificationbar) if self._show_statusbar: lines.append(self.build_statusbar()) if lines: mainframe.set_footer(urwid.Pile(lines)) else: mainframe.set_footer(None) # force a screen redraw if self.mainloop.screen.started and redraw: self.mainloop.draw_screen() def build_statusbar(self): """construct and return statusbar widget""" info = {} cb = self.current_buffer btype = None if cb is not None: info = cb.get_info() btype = cb.modename info['buffer_no'] = self.buffers.index(cb) info['buffer_type'] = btype info['total_messages'] = self.dbman.count_messages('*') info['pending_writes'] = len(self.dbman.writequeue) info['input_queue'] = ' '.join(self.input_queue) lefttxt = righttxt = '' if cb is not None: lefttxt, righttxt = settings.get(btype + '_statusbar', ('', '')) lefttxt = string_decode(lefttxt, 'UTF-8') lefttxt = lefttxt.format(**info) righttxt = string_decode(righttxt, 'UTF-8') righttxt = righttxt.format(**info) footerleft = urwid.Text(lefttxt, align='left') pending_writes = len(self.dbman.writequeue) if pending_writes > 0: righttxt = ('|' * pending_writes) + ' ' + righttxt footerright = urwid.Text(righttxt, align='right') columns = urwid.Columns([ footerleft, ('pack', footerright)]) footer_att = settings.get_theming_attribute('global', 'footer') return urwid.AttrMap(columns, footer_att) async def apply_command(self, cmd): """ applies a command This calls the pre and post hooks attached to the command, as well as :meth:`cmd.apply`. :param cmd: an applicable command :type cmd: :class:`~alot.commands.Command` """ # FIXME: What are we guarding for here? We don't mention that None is # allowed as a value fo cmd. if cmd: if cmd.prehook: await cmd.prehook(ui=self, dbm=self.dbman, cmd=cmd) try: if asyncio.iscoroutinefunction(cmd.apply): await cmd.apply(self) else: cmd.apply(self) except Exception as e: self._error_handler(e) else: if cmd.posthook: logging.info('calling post-hook') await cmd.posthook(ui=self, dbm=self.dbman, cmd=cmd) def _handle_signal(self, signum, _frame): """ Handle UNIX signals: add a new task onto the event loop. Doing it this way ensures what our handler has access to whatever synchronization primitives or async calls it may require. """ loop = asyncio.get_event_loop() asyncio.run_coroutine_threadsafe(self.handle_signal(signum), loop) async def handle_signal(self, signum): """ handles UNIX signals This function currently just handles SIGUSR1. It could be extended to handle more :param signum: The signal number (see man 7 signal) """ # it is a SIGINT ? if signum == signal.SIGINT: logging.info('shut down cleanly') await self.apply_command(globals.ExitCommand()) elif signum == signal.SIGUSR1: if isinstance(self.current_buffer, SearchBuffer): self.current_buffer.rebuild() self.update() def cleanup(self): """Do the final clean up before shutting down.""" size = settings.get('history_size') self._save_history_to_file(self.commandprompthistory, self._cmd_hist_file, size=size) self._save_history_to_file(self.senderhistory, self._sender_hist_file, size=size) self._save_history_to_file(self.recipienthistory, self._recipients_hist_file, size=size) @staticmethod def _load_history_from_file(path, size=-1): """Load a history list from a file and split it into lines. :param path: the path to the file that should be loaded :type path: str :param size: the number of lines to load (0 means no lines, < 0 means all lines) :type size: int :returns: a list of history items (the lines of the file) :rtype: list(str) """ if size == 0: return [] if os.path.exists(path): with codecs.open(path, 'r', encoding='utf-8') as histfile: lines = [line.rstrip('\n') for line in histfile] if size > 0: lines = lines[-size:] return lines else: return [] @staticmethod def _save_history_to_file(history, path, size=-1): """Save a history list to a file for later loading (possibly in another session). :param history: the history list to save :type history: list(str) :param path: the path to the file where to save the history :param size: the number of lines to save (0 means no lines, < 0 means all lines) :type size: int :type path: str :returns: None """ if size == 0: return if size > 0: history = history[-size:] directory = os.path.dirname(path) if not os.path.exists(directory): os.makedirs(directory) # Write linewise to avoid building a large string in menory. with codecs.open(path, 'w', encoding='utf-8') as histfile: for line in history: histfile.write(line) histfile.write('\n') alot-0.11/alot/utils/000077500000000000000000000000001466311112200144665ustar00rootroot00000000000000alot-0.11/alot/utils/__init__.py000066400000000000000000000000001466311112200165650ustar00rootroot00000000000000alot-0.11/alot/utils/ansi.py000066400000000000000000000023431466311112200157740ustar00rootroot00000000000000# This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import re _b1 = r'\033\[' # Control Sequence Introducer _b2 = r'[0-9:;<=>?]*' # parameter bytes _b3 = r'[ !\"#$%&\'()*+,-./]*' # intermediate bytes _b4 = r'[A-Z[\]^_`a-z{|}~]' # final byte" esc_pattern = re.compile( _b1 + r'(?P' + _b2 + ')' + r'(?P' + _b3 + ')' + r'(?P' + _b4 + ')') def parse_csi(text): """Parse text and yield tuples for ANSI CSIs found in it. Each tuple is in the format ``(pb, ib, fb, s)`` with the parameter bytes (pb), the intermediate bytes (ib), the final byte (fb) and the substring (s) between this and the next CSI (or the end of the string). Note that the first tuple will always be ``(None, None, None, s)`` with ``s`` being the substring prior to the first CSI (or the end of the string if none was found). """ i = 0 pb, ib, fb = None, None, None for m in esc_pattern.finditer(text): yield pb, ib, fb, text[i:m.start()] pb, ib, fb = m.groups() i = m.end() yield pb, ib, fb, text[i:] def remove_csi(text): """Return text with ANSI CSIs removed.""" return "".join(s for *_, s in parse_csi(text)) alot-0.11/alot/utils/argparse.py000066400000000000000000000111221466311112200166410ustar00rootroot00000000000000# encoding=utf-8 # Copyright (C) Patrick Totzke # Copyright © 2017 Dylan Baker # 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 . """Custom extensions of the argparse module.""" import argparse import collections import functools import itertools import os import stat _TRUEISH = ['true', 'yes', 'on', '1', 't', 'y'] _FALSISH = ['false', 'no', 'off', '0', 'f', 'n'] class ValidationFailed(Exception): """Exception raised when Validation fails in a ValidatedStoreAction.""" pass def _boolean(string): string = string.lower() if string in _FALSISH: return False elif string in _TRUEISH: return True else: raise ValueError('Option must be one of: {}'.format( ', '.join(itertools.chain(iter(_TRUEISH), iter(_FALSISH))))) def _path_factory(check): """Create a function that checks paths.""" @functools.wraps(check) def validator(paths): if isinstance(paths, str): check(paths) elif isinstance(paths, collections.Sequence): for path in paths: check(path) else: raise Exception('expected either basestr or sequenc of basstr') return validator @_path_factory def require_file(path): """Validator that asserts that a file exists. This fails if there is nothing at the given path. """ if not os.path.isfile(path): raise ValidationFailed('{} is not a valid file.'.format(path)) @_path_factory def optional_file_like(path): """Validator that ensures that if a file exists it regular, a fifo, or a character device. The file is not required to exist. This includes character special devices like /dev/null. """ if (os.path.exists(path) and not (os.path.isfile(path) or stat.S_ISFIFO(os.stat(path).st_mode) or stat.S_ISCHR(os.stat(path).st_mode))): raise ValidationFailed( '{} is not a valid file, character device, or fifo.'.format(path)) @_path_factory def require_dir(path): """Validator that asserts that a directory exists. This fails if there is nothing at the given path. """ if not os.path.isdir(path): raise ValidationFailed('{} is not a valid directory.'.format(path)) def is_int_or_pm(value): """Validator to assert that value is '+', '-', or an integer""" if value not in ['+', '-']: try: value = int(value) except ValueError: raise ValidationFailed('value must be an integer or "+" or "-".') return value class BooleanAction(argparse.Action): """Argparse action that can be used to store boolean values.""" def __init__(self, *args, **kwargs): kwargs['type'] = _boolean kwargs['metavar'] = 'BOOL' argparse.Action.__init__(self, *args, **kwargs) def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, self.dest, values) class ValidatedStoreAction(argparse.Action): """An action that allows a validation function to be specificied. The validator keyword must be a function taking exactly one argument, that argument is a list of strings or the type specified by the type argument. It must raise ValidationFailed with a message when validation fails. """ def __init__(self, option_strings, dest=None, nargs=None, default=None, required=False, type=None, metavar=None, help=None, validator=None): super(ValidatedStoreAction, self).__init__( option_strings=option_strings, dest=dest, nargs=nargs, default=default, required=required, metavar=metavar, type=type, help=help) self.validator = validator def __call__(self, parser, namespace, values, option_string=None): if self.validator: try: self.validator(values) except ValidationFailed as e: raise argparse.ArgumentError(self, str(e)) setattr(namespace, self.dest, values) alot-0.11/alot/utils/cached_property.py000066400000000000000000000057711466311112200202250ustar00rootroot00000000000000# verbatim from werkzeug.utils.cached_property # # Copyright (c) 2014 by the Werkzeug Team, see AUTHORS for more details. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # * The names of the contributors may not be used to endorse or # promote products derived from this software without specific # prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. _missing = object() class cached_property: """A decorator that converts a function into a lazy property. The function wrapped is called the first time to retrieve the result and then that calculated result is used the next time you access the value:: class Foo: @cached_property def foo(self): # calculate something important here return 42 The class has to have a `__dict__` in order for this property to work. """ # implementation detail: this property is implemented as non-data # descriptor. non-data descriptors are only invoked if there is # no entry with the same name in the instance's __dict__. # this allows us to completely get rid of the access function call # overhead. If one choses to invoke __get__ by hand the property # will still work as expected because the lookup logic is replicated # in __get__ for manual invocation. def __init__(self, func, name=None, doc=None): self.__name__ = name or func.__name__ self.__module__ = func.__module__ self.__doc__ = doc or func.__doc__ self.func = func def __get__(self, obj, type=None): if obj is None: return self value = obj.__dict__.get(self.__name__, _missing) if value is _missing: value = self.func(obj) obj.__dict__[self.__name__] = value return value alot-0.11/alot/utils/collections.py000066400000000000000000000012701466311112200173560ustar00rootroot00000000000000# This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file from collections.abc import Set # for backward compatibility with Python <3.7 from collections import OrderedDict class OrderedSet(Set): """ Ordered collection of distinct hashable objects. Taken from https://stackoverflow.com/a/10006674 """ def __init__(self, iterable=()): self.d = OrderedDict.fromkeys(iterable) def __len__(self): return len(self.d) def __contains__(self, element): return element in self.d def __iter__(self): return iter(self.d) def __repr__(self): return str(list(self)) alot-0.11/alot/utils/configobj.py000066400000000000000000000111231466311112200167760ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import mailbox import os import re from urllib.parse import urlparse from validate import VdtTypeError from validate import is_list from validate import ValidateError, VdtValueTooLongError, VdtValueError from urwid import AttrSpec, AttrSpecError from .. import crypto from ..errors import GPGProblem def attr_triple(value): """ Check that interprets the value as `urwid.AttrSpec` triple for the colour modes 1,16 and 256. It assumes a <6 tuple of attribute strings for mono foreground, mono background, 16c fg, 16c bg, 256 fg and 256 bg respectively. If any of these are missing, we downgrade to the next lower available pair, defaulting to 'default'. :raises: VdtValueTooLongError, VdtTypeError :rtype: triple of `urwid.AttrSpec` """ keys = ['dfg', 'dbg', '1fg', '1bg', '16fg', '16bg', '256fg', '256bg'] acc = {} if not isinstance(value, (list, tuple)): value = value, value = list(value) # sometimes we end up with tuples here if len(value) > 6: raise VdtValueTooLongError(value) # ensure we have exactly 6 attribute strings attrstrings = (value + (6 - len(value)) * [None])[:6] # add fallbacks for the empty list attrstrings = (2 * ['default']) + attrstrings for i, value in enumerate(attrstrings): if value: acc[keys[i]] = value else: acc[keys[i]] = acc[keys[i - 2]] try: mono = AttrSpec(acc['1fg'], acc['1bg'], 1) normal = AttrSpec(acc['16fg'], acc['16bg'], 16) high = AttrSpec(acc['256fg'], acc['256bg'], 256) except AttrSpecError as e: raise ValidateError(str(e)) return mono, normal, high def align_mode(value): """ test if value is one of 'left', 'right' or 'center' """ if value not in ['left', 'right', 'center']: raise VdtValueError return value def width_tuple(value): """ test if value is a valid width indicator (for a sub-widget in a column). This can either be ('fit', min, max): use the length actually needed for the content, padded to use at least width min, and cut of at width max. Here, min and max are positive integers or 0 to disable the boundary. ('weight',n): have it relative weight of n compared to other columns. Here, n is an int. """ if value is None: res = 'fit', 0, 0 elif not isinstance(value, (list, tuple)): raise VdtTypeError(value) elif value[0] not in ['fit', 'weight']: raise VdtTypeError(value) try: if value[0] == 'fit': res = 'fit', int(value[1]), int(value[2]) else: res = 'weight', int(value[1]) except IndexError: raise VdtTypeError(value) except ValueError: raise VdtValueError(value) return res def mail_container(value): """ Check that the value points to a valid mail container, in URI-style, e.g.: `mbox:///home/username/mail/mail.box`. `~`-expansion will work, e.g.: `mbox://~/mail/mail.box`. The value is cast to a :class:`mailbox.Mailbox` object. """ if not re.match(r'.*://.*', value): raise VdtTypeError(value) mburl = urlparse(value) uri_scheme_to_mbclass = { 'mbox': mailbox.mbox, 'maildir': mailbox.Maildir, 'mh': mailbox.MH, 'babyl': mailbox.Babyl, 'mmdf': mailbox.MMDF, } klass = uri_scheme_to_mbclass.get(mburl.scheme) if klass: return klass(os.path.expandvars(mburl.netloc + mburl.path)) raise VdtTypeError(value) def force_list(value, min=None, max=None): r""" Check that a value is a list, coercing strings into a list with one member. You can optionally specify the minimum and maximum number of members. A minimum of greater than one will fail if the user only supplies a string. The difference to :func:`validate.force_list` is that this test will return an empty list instead of `['']` if the config value matches `r'\s*,?\s*'`. """ if not isinstance(value, (list, tuple)): value = [value] rlist = is_list(value, min, max) if rlist == ['']: rlist = [] return rlist def gpg_key(value): """ test if value points to a known gpg key and return that key as a gpg key object. """ try: return crypto.get_key(value) except GPGProblem as e: raise ValidateError(str(e)) alot-0.11/alot/walker.py000066400000000000000000000060461466311112200151730ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # Copyright © 2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import logging import urwid class IterableWalker(urwid.ListWalker): """An urwid walker for iterables. Works like ListWalker, except it takes an iterable object instead of a concrete type. This allows for lazy operations of very large sequences of data, such as a sequences of threads with certain notmuch tags. :param iterable: An iterator of objects to walk over :type iterable: Iterable[T] :param containerclass: An urwid widget to wrap each object in :type containerclass: urwid.Widget :param reverse: Reverse the order of the iterable :type reverse: bool :param **kwargs: Forwarded to container class. """ def __init__(self, iterable, containerclass, reverse=False, **kwargs): self.iterable = iterable self.kwargs = kwargs self.containerclass = containerclass self.lines = [] self.focus = 0 self.empty = False self.direction = -1 if reverse else 1 def __contains__(self, name): return self.lines.__contains__(name) def get_focus(self): return self._get_at_pos(self.focus) def set_focus(self, focus): self.focus = focus self._modified() def get_next(self, start_from): return self._get_at_pos(start_from + self.direction) def get_prev(self, start_from): return self._get_at_pos(start_from - self.direction) def remove(self, obj): next_focus = self.focus % len(self.lines) if self.focus == len(self.lines) - 1 and self.empty: next_focus = self.focus - 1 self.lines.remove(obj) if self.lines: self.set_focus(next_focus) self._modified() def _get_at_pos(self, pos): if pos < 0: # pos too low return (None, None) elif pos > len(self.lines): # pos too high return (None, None) elif len(self.lines) > pos: # pos already cached return (self.lines[pos], pos) else: # pos not cached yet, look at next item from iterator if self.empty: # iterator is empty return (None, None) else: widget = self._get_next_item() if widget: return (widget, pos) else: return (None, None) def _get_next_item(self): if self.empty: return None try: # the next line blocks until it can read from the pipe or # EOFError is raised. No races here. next_obj = next(self.iterable) next_widget = self.containerclass(next_obj, **self.kwargs) self.lines.append(next_widget) except StopIteration: logging.debug('EMPTY PIPE') next_widget = None self.empty = True return next_widget def get_lines(self): return self.lines alot-0.11/alot/widgets/000077500000000000000000000000001466311112200147745ustar00rootroot00000000000000alot-0.11/alot/widgets/__init__.py000066400000000000000000000000001466311112200170730ustar00rootroot00000000000000alot-0.11/alot/widgets/ansi.py000066400000000000000000000111061466311112200162770ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import urwid from ..utils import ansi class ANSIText(urwid.WidgetWrap): """Selectable Text widget that interprets ANSI color codes""" def __init__(self, txt, default_attr=None, default_attr_focus=None, ansi_background=True, mimepart=False, **kwds): self.mimepart = mimepart ct, focus_map = parse_escapes_to_urwid(txt, default_attr, default_attr_focus, ansi_background) t = urwid.Text(ct, **kwds) attr_map = {default_attr.background: ''} w = urwid.AttrMap(t, attr_map, focus_map) urwid.WidgetWrap.__init__(self, w) def selectable(self): return True def keypress(self, size, key): return key ECODES = { '1': {'bold': True}, '3': {'italics': True}, '4': {'underline': True}, '5': {'blink': True}, '7': {'standout': True}, '9': {'strikethrough': True}, '30': {'fg': 'black'}, '31': {'fg': 'dark red'}, '32': {'fg': 'dark green'}, '33': {'fg': 'brown'}, '34': {'fg': 'dark blue'}, '35': {'fg': 'dark magenta'}, '36': {'fg': 'dark cyan'}, '37': {'fg': 'light gray'}, '40': {'bg': 'black'}, '41': {'bg': 'dark red'}, '42': {'bg': 'dark green'}, '43': {'bg': 'brown'}, '44': {'bg': 'dark blue'}, '45': {'bg': 'dark magenta'}, '46': {'bg': 'dark cyan'}, '47': {'bg': 'light gray'}, } URWID_MODS = [ 'bold', 'underline', 'standout', 'blink', 'italics', 'strikethrough', ] def parse_escapes_to_urwid(text, default_attr=None, default_attr_focus=None, parse_background=True): """This function converts a text with ANSI escape for terminal attributes and returns a list containing each part of text and its corresponding Urwid Attributes object, it also returns a dictionary which maps all attributes applied here to focused attribute. This will only translate (a subset of) CSI sequences: we interpret only SGR parameters that urwid supports (excluding true color) See https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_sequences """ # these two will be returned urwid_text = [] # we will accumulate text (with attributes) here # mapping from included attributes to focused attr urwid_focus = {None: default_attr_focus} # Escapes are cumulative so we always keep previous values until it's # changed by another escape. attr = dict(fg=default_attr.foreground, bg=default_attr.background, bold=default_attr.bold, underline=default_attr.underline, standout=default_attr.underline) def append_themed_infix(infix): # add using prev attribute urwid_fg = attr['fg'] urwid_bg = default_attr.background for mod in URWID_MODS: if mod in attr and attr[mod]: urwid_fg += ',' + mod if parse_background: urwid_bg = attr['bg'] urwid_attr = urwid.AttrSpec(urwid_fg, urwid_bg) urwid_focus[urwid_attr] = default_attr_focus urwid_text.append((urwid_attr, infix)) def reset_attr(): attr.clear() attr.update(fg=default_attr.foreground, bg=default_attr.background, bold=default_attr.bold, underline=default_attr.underline, standout=default_attr.underline) def update_attr(pb, _, fb): if fb == 'm': # selector bit found. this means theming changes if not pb or pb == "0": reset_attr() elif pb.startswith('38;5;'): # 8-bit colour foreground col = pb[5:] attr.update(fg='h' + col) elif pb.startswith('48;5;') and parse_background: # 8-bit colour background col = pb[5:] attr.update(bg='h' + col) else: # Several attributes can be set in the same sequence, # separated by semicolons. Interpret them acc to ECODES codes = pb.split(';') for code in codes: if code in ECODES: attr.update(ECODES[code]) for pb, ib, fb, infix in ansi.parse_csi(text): update_attr(pb, ib, fb) append_themed_infix(infix) return urwid_text, urwid_focus alot-0.11/alot/widgets/bufferlist.py000066400000000000000000000013141466311112200175120ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file """ Widgets specific to Bufferlist mode """ import urwid class BufferlineWidget(urwid.Text): """ selectable text widget that represents a :class:`~alot.buffers.Buffer` in the :class:`~alot.buffers.BufferlistBuffer`. """ def __init__(self, buffer): self.buffer = buffer line = buffer.__str__() urwid.Text.__init__(self, line, wrap='clip') def selectable(self): return True def keypress(self, size, key): return key def get_buffer(self): return self.buffer alot-0.11/alot/widgets/globals.py000066400000000000000000000330631466311112200167760ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file """ This contains alot-specific :class:`urwid.Widget` used in more than one mode. """ import re import operator import urwid from ..helper import string_decode from ..settings.const import settings from ..db.attachment import Attachment from ..errors import CompletionError class AttachmentWidget(urwid.WidgetWrap): """ one-line summary of an :class:`~alot.db.attachment.Attachment`. """ def __init__(self, attachment, selectable=True): self._selectable = selectable self.attachment = attachment if not isinstance(attachment, Attachment): self.attachment = Attachment(self.attachment) att = settings.get_theming_attribute('thread', 'attachment') focus_att = settings.get_theming_attribute('thread', 'attachment_focus') widget = urwid.AttrMap(urwid.Text(self.attachment.__str__()), att, focus_att) urwid.WidgetWrap.__init__(self, widget) def get_attachment(self): return self.attachment def selectable(self): return self._selectable def keypress(self, size, key): return key class ChoiceWidget(urwid.Text): def __init__(self, choices, callback, cancel=None, select=None, separator=' ', choices_to_return=None): self.choices = choices self.choices_to_return = choices_to_return or {} self.callback = callback self.cancel = cancel self.select = select self.separator = separator items = [] for k, v in choices.items(): if v == select and select is not None: items += ['[', k, ']:', v] else: items += ['(', k, '):', v] items += [self.separator] urwid.Text.__init__(self, items) def selectable(self): return True def keypress(self, size, key): if key == 'enter' and self.select is not None: self.callback(self.select) elif key == 'esc' and self.cancel is not None: self.callback(self.cancel) elif key in self.choices_to_return: self.callback(self.choices_to_return[key]) elif key in self.choices: self.callback(self.choices[key]) else: return key class CompleteEdit(urwid.Edit): """ This is a vamped-up :class:`urwid.Edit` widget that allows for tab-completion using :class:`~alot.completion.Completer` objects These widgets are meant to be used as user input prompts and hence react to 'return' key presses by calling a 'on_exit' callback that processes the current text value. The interpretation of some keypresses is hard-wired: :enter: calls 'on_exit' callback with current value :esc/ctrl g: calls 'on_exit' with value `None`, which can be interpreted as cancellation :tab: calls the completer and tabs forward in the result list :shift tab: tabs backward in the result list :up/down: move in the local input history :ctrl f/b: moves curser one character to the right/left :meta f/b shift right/left: moves the cursor one word to the right/left :ctrl a/e: moves curser to the beginning/end of the input :ctrl d: deletes the character under the cursor :meta d: deletes everything from the cursor to the end of the next word :meta delete/backspace ctrl w: deletes everything from the cursor to the beginning of the current word :ctrl k: deletes everything from the cursor to the end of the input :ctrl u: deletes everything from the cursor to the beginning of the input """ def __init__(self, completer, on_exit, on_error=None, edit_text='', history=None, **kwargs): """ :param completer: completer to use :type completer: alot.completion.Completer :param on_exit: "enter"-callback that interprets the input (str) :type on_exit: callable :param on_error: callback that handles :class:`alot.errors.CompletionErrors` :type on_error: callback :param edit_text: initial text :type edit_text: str :param history: initial command history :type history: list or str """ self.completer = completer self.on_exit = on_exit self.on_error = on_error self.history = list(history) # we temporarily add stuff here self.historypos = None self.focus_in_clist = 0 if not isinstance(edit_text, str): edit_text = string_decode(edit_text) self.start_completion_pos = len(edit_text) self.completions = None urwid.Edit.__init__(self, edit_text=edit_text, **kwargs) def keypress(self, size, key): # if we tabcomplete if key in ['tab', 'shift tab'] and self.completer: # if not already in completion mode if self.completions is None: self.completions = [(self.edit_text, self.edit_pos)] try: self.completions += self.completer.complete(self.edit_text, self.edit_pos) self.focus_in_clist = 1 except CompletionError as e: if self.on_error is not None: self.on_error(e) else: # otherwise tab through results if key == 'tab': self.focus_in_clist += 1 else: self.focus_in_clist -= 1 if len(self.completions) > 1: ctext, cpos = self.completions[self.focus_in_clist % len(self.completions)] self.set_edit_text(ctext) self.set_edit_pos(cpos) else: self.completions = None elif key in ['up', 'down']: if self.history: if self.historypos is None: self.history.append(self.edit_text) self.historypos = len(self.history) - 1 if key == 'up': self.historypos = (self.historypos - 1) % len(self.history) else: self.historypos = (self.historypos + 1) % len(self.history) self.set_edit_text(self.history[self.historypos]) elif key == 'enter': self.on_exit(self.edit_text) elif key in ('ctrl g', 'esc'): self.on_exit(None) elif key == 'ctrl a': self.set_edit_pos(0) elif key == 'ctrl e': self.set_edit_pos(len(self.edit_text)) elif key == 'ctrl f': self.set_edit_pos(min(self.edit_pos+1, len(self.edit_text))) elif key == 'ctrl b': self.set_edit_pos(max(self.edit_pos-1, 0)) elif key == 'ctrl k': self.edit_text = self.edit_text[:self.edit_pos] elif key == 'ctrl u': self.edit_text = self.edit_text[self.edit_pos:] self.set_edit_pos(0) elif key == 'ctrl d': self.edit_text = (self.edit_text[:self.edit_pos] + self.edit_text[self.edit_pos+1:]) elif key in ('meta f', 'shift right'): self.move_to_next_word(forward=True) elif key in ('meta b', 'shift left'): self.move_to_next_word(forward=False) elif key == 'meta d': start_pos = self.edit_pos end_pos = self.move_to_next_word(forward=True) if end_pos is not None: self.edit_text = (self.edit_text[:start_pos] + self.edit_text[end_pos:]) self.set_edit_pos(start_pos) elif key in ('meta delete', 'meta backspace', 'ctrl w'): end_pos = self.edit_pos start_pos = self.move_to_next_word(forward=False) if start_pos is not None: self.edit_text = (self.edit_text[:start_pos] + self.edit_text[end_pos:]) self.set_edit_pos(start_pos) else: result = urwid.Edit.keypress(self, size, key) self.completions = None return result def move_to_next_word(self, forward=True): if forward: match_iterator = re.finditer(r'(\b\W+|$)', self.edit_text, flags=re.UNICODE) match_positions = [m.start() for m in match_iterator] op = operator.gt else: match_iterator = re.finditer(r'(\w+\b|^)', self.edit_text, flags=re.UNICODE) match_positions = reversed([m.start() for m in match_iterator]) op = operator.lt for pos in match_positions: if op(pos, self.edit_pos): self.set_edit_pos(pos) return pos class HeadersList(urwid.WidgetWrap): """ renders a pile of header values as key/value list """ def __init__(self, headerslist, key_attr, value_attr, gaps_attr=None): """ :param headerslist: list of key/value pairs to display :type headerslist: list of (str, str) :param key_attr: theming attribute to use for keys :type key_attr: urwid.AttrSpec :param value_attr: theming attribute to use for values :type value_attr: urwid.AttrSpec :param gaps_attr: theming attribute to wrap lines in :type gaps_attr: urwid.AttrSpec """ self.headers = headerslist self.key_attr = key_attr self.value_attr = value_attr pile = urwid.Pile(self._build_lines(headerslist)) if gaps_attr is None: gaps_attr = key_attr pile = urwid.AttrMap(pile, gaps_attr) urwid.WidgetWrap.__init__(self, pile) def __str__(self): return str(self.headers) def _build_lines(self, lines): max_key_len = 1 headerlines = [] # calc max length of key-string for key, value in lines: if len(key) > max_key_len: max_key_len = len(key) for key, value in lines: # todo : even/odd keyw = ('fixed', max_key_len + 1, urwid.Text((self.key_attr, key))) valuew = urwid.Text((self.value_attr, value)) line = urwid.Columns([keyw, valuew]) headerlines.append(line) return headerlines class TagWidget(urwid.AttrMap): """ text widget that renders a tagstring. It looks up the string it displays in the `tags` section of the config as well as custom theme settings for its tag. Attributes that should be considered publicly readable: :attr tag: the notmuch tag :type tag: str """ def __init__(self, tag, fallback_normal=None, fallback_focus=None): self.tag = tag representation = settings.get_tagstring_representation(tag, fallback_normal, fallback_focus) self.translated = representation['translated'] self.hidden = self.translated == '' self.txt = urwid.Text(self.translated, wrap='clip') self.__hash = hash((self.translated, self.txt)) normal_att = representation['normal'] focus_att = representation['focussed'] self.attmaps = {'normal': normal_att, 'focus': focus_att} urwid.AttrMap.__init__(self, self.txt, normal_att, focus_att) def set_map(self, attrstring): self.set_attr_map({None: self.attmaps[attrstring]}) def width(self): # evil voodoo hotfix for double width chars that may # lead e.g. to strings with length 1 that need width 2 return self.txt.pack()[0] def selectable(self): return True def keypress(self, size, key): return key def set_focussed(self): self.set_attr_map(self.attmaps['focus']) def set_unfocussed(self): self.set_attr_map(self.attmaps['normal']) def __cmp(self, other, comparitor): """Shared comparison method.""" if not isinstance(other, TagWidget): return NotImplemented self_len = len(self.translated) oth_len = len(other.translated) if (self_len == 1) is not (oth_len == 1): return comparitor(self_len, oth_len) return comparitor(self.translated.lower(), other.translated.lower()) def __lt__(self, other): """Groups tags of 1 character first, then alphabetically. This groups tags unicode characters at the begnining. """ return self.__cmp(other, operator.lt) def __gt__(self, other): return self.__cmp(other, operator.gt) def __ge__(self, other): return self.__cmp(other, operator.ge) def __le__(self, other): return self.__cmp(other, operator.le) def __eq__(self, other): if not isinstance(other, TagWidget): return NotImplemented if len(self.translated) != len(other.translated): return False return self.translated.lower() == other.translated.lower() def __ne__(self, other): if not isinstance(other, TagWidget): return NotImplemented return self.translated.lower() != other.translated.lower() def __hash__(self): return self.__hash alot-0.11/alot/widgets/namedqueries.py000066400000000000000000000015141466311112200200310ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file """ Widgets specific to Namedqueries mode """ import urwid class QuerylineWidget(urwid.Columns): def __init__(self, key, value, count, count_unread): self.query = key count_widget = urwid.Text('{0:>7} {1:7}'. format(count, '({0})'.format(count_unread))) key_widget = urwid.Text(key) value_widget = urwid.Text(value) urwid.Columns.__init__(self, (key_widget, count_widget, value_widget), dividechars=1) def selectable(self): return True def keypress(self, size, key): return key def get_query(self): return self.query alot-0.11/alot/widgets/search.py000066400000000000000000000157601466311112200166240ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file """ Widgets specific to search mode """ import urwid from ..settings.const import settings from ..helper import shorten_author_string from .utils import AttrFlipWidget from .globals import TagWidget class ThreadlineWidget(urwid.AttrMap): """ selectable line widget that represents a :class:`~alot.db.Thread` in the :class:`~alot.buffers.SearchBuffer`. """ def __init__(self, tid, dbman): self.dbman = dbman self.tid = tid self.thread = None # will be set by refresh() self.tag_widgets = [] self.structure = None self.rebuild() normal = self.structure['normal'] focussed = self.structure['focus'] urwid.AttrMap.__init__(self, self.columns, normal, focussed) def rebuild(self): self.thread = self.dbman.get_thread(self.tid) self.widgets = [] self.structure = settings.get_threadline_theming(self.thread) columns = [] # combine width info and widget into an urwid.Column entry def add_column(width, part): width_tuple = self.structure[partname]['width'] if width_tuple[0] == 'weight': columnentry = width_tuple + (part,) else: columnentry = ('fixed', width, part) columns.append(columnentry) # create a column for every part of the threadline for partname in self.structure['parts']: # build widget(s) around this part's content and remember them so # that self.render() may change local attributes. if partname == 'tags': width, part = build_tags_part(self.thread.get_tags(), self.structure['tags']['normal'], self.structure['tags']['focus']) if part: add_column(width, part) for w in part.widget_list: self.widgets.append(w) else: width, part = build_text_part(partname, self.thread, self.structure[partname]) add_column(width, part) self.widgets.append(part) self.columns = urwid.Columns(columns, dividechars=1) self.original_widget = self.columns def render(self, size, focus=False): for w in self.widgets: w.set_map('focus' if focus else 'normal') return urwid.AttrMap.render(self, size, focus) def selectable(self): return True def keypress(self, size, key): return key def get_thread(self): return self.thread def build_tags_part(tags, attr_normal, attr_focus): """ create an urwid.Columns widget (wrapped in approproate Attributes) to display a list of tag strings, as part of a threadline. :param tags: list of tag strings to include :type tags: list of str :param attr_normal: urwid attribute to use if unfocussed :param attr_focus: urwid attribute to use if focussed :return: overall width in characters and a Columns widget. :rtype: tuple[int, urwid.Columns] """ part_w = None width = None tag_widgets = [] cols = [] width = -1 # create individual TagWidgets and sort them tag_widgets = [TagWidget(t, attr_normal, attr_focus) for t in tags] tag_widgets = sorted(tag_widgets) for tag_widget in tag_widgets: if not tag_widget.hidden: wrapped_tagwidget = tag_widget tag_width = tag_widget.width() cols.append(('fixed', tag_width, wrapped_tagwidget)) width += tag_width + 1 if cols: part_w = urwid.Columns(cols, dividechars=1) return width, part_w def build_text_part(name, thread, struct): """ create an urwid.Text widget (wrapped in approproate Attributes) to display a plain text parts in a threadline. create an urwid.Columns widget (wrapped in approproate Attributes) to display a list of tag strings, as part of a threadline. :param name: id of part to build :type name: str :param thread: the thread to get local info for :type thread: :class:`alot.db.thread.Thread` :param struct: theming attributes for this part, as provided by :class:`alot.settings.theme.Theme.get_threadline_theming` :type struct: dict :return: overall width (in characters) and a widget. :rtype: tuple[int, AttrFliwWidget] """ part_w = None width = None # extract min and max allowed width from theme minw = 0 maxw = None width_tuple = struct['width'] if width_tuple is not None: if width_tuple[0] == 'fit': minw, maxw = width_tuple[1:] content = prepare_string(name, thread, maxw) # pad content if not long enough if minw: alignment = struct['alignment'] if alignment == 'left': content = content.ljust(minw) elif alignment == 'center': content = content.center(minw) else: content = content.rjust(minw) # define width and part_w text = urwid.Text(content, wrap='clip') width = text.pack((maxw or minw,))[0] part_w = AttrFlipWidget(text, struct) return width, part_w def prepare_date_string(thread): newest = thread.get_newest_date() if newest is not None: datestring = settings.represent_datetime(newest) return datestring def prepare_mailcount_string(thread): return "(%d)" % thread.get_total_messages() def prepare_authors_string(thread): return thread.get_authors_string() or '(None)' def prepare_subject_string(thread): return thread.get_subject() or ' ' def prepare_content_string(thread): msgs = sorted(thread.get_messages().keys(), key=lambda msg: msg.get_date(), reverse=True) lastcontent = ' '.join(m.get_body_text() for m in msgs) lastcontent = lastcontent.replace('^>.*$', '') return lastcontent def prepare_string(partname, thread, maxw): """ extract a content string for part 'partname' from 'thread' of maximal length 'maxw'. """ # map part names to function extracting content string and custom shortener prep = { 'mailcount': (prepare_mailcount_string, None), 'date': (prepare_date_string, None), 'authors': (prepare_authors_string, shorten_author_string), 'subject': (prepare_subject_string, None), 'content': (prepare_content_string, None), } s = ' ' # fallback value if thread: # get extractor and shortener content, shortener = prep[partname] # get string s = content(thread) # sanitize s = s.replace('\n', ' ') s = s.replace('\r', '') # shorten if max width is requested if maxw: if len(s) > maxw and shortener: s = shortener(s, maxw) else: s = s[:maxw] return s alot-0.11/alot/widgets/thread.py000066400000000000000000000403061466311112200166200ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file """ Widgets specific to thread mode """ import email import logging import urwid from urwidtrees import Tree, SimpleTree, CollapsibleTree, ArrowTree from .ansi import ANSIText from .globals import TagWidget from .globals import AttachmentWidget from ..settings.const import settings from ..db.attachment import Attachment from ..db.utils import decode_header, X_SIGNATURE_MESSAGE_HEADER from ..helper import string_sanitize ANSI_BACKGROUND = settings.get("interpret_ansi_background") class MessageSummaryWidget(urwid.WidgetWrap): """ one line summary of a :class:`~alot.db.message.Message`. """ def __init__(self, message, even=True): """ :param message: a message :type message: alot.db.Message :param even: even entry in a pile of messages? Used for theming. :type even: bool """ self.message = message self.even = even if even: attr = settings.get_theming_attribute('thread', 'summary', 'even') else: attr = settings.get_theming_attribute('thread', 'summary', 'odd') focus_att = settings.get_theming_attribute('thread', 'summary', 'focus') cols = [] sumstr = self.__str__() txt = urwid.Text(sumstr) cols.append(txt) if settings.get('msg_summary_hides_threadwide_tags'): thread_tags = message.get_thread().get_tags(intersection=True) outstanding_tags = set(message.get_tags()).difference(thread_tags) tag_widgets = sorted(TagWidget(t, attr, focus_att) for t in outstanding_tags) else: tag_widgets = sorted(TagWidget(t, attr, focus_att) for t in message.get_tags()) for tag_widget in tag_widgets: if not tag_widget.hidden: cols.append(('fixed', tag_widget.width(), tag_widget)) line = urwid.AttrMap(urwid.Columns(cols, dividechars=1), attr, focus_att) # In case when an e-mail has multiple tags, Urwid assumes that only # one of the summary line's TagWidget-s can be focused. Which means # that when the summary line is focused, only one TagWidget is rendered # as focused and the rest of them as unfocused. This forces to render # all TagWidget-s as focused. Issue #1433 def _render_wrap(size, focus=False): for c in cols: if isinstance(c, tuple): c[2].set_map('focus' if focus else 'normal') return urwid.AttrMap.render(line, size, focus) line.render = _render_wrap urwid.WidgetWrap.__init__(self, line) def __str__(self): author, address = self.message.get_author() date = self.message.get_datestring() rep = author if author != '' else address if date is not None: rep += " (%s)" % date return rep def selectable(self): return True def keypress(self, size, key): return key class TextlinesList(SimpleTree): def __init__(self, content, attr=None, attr_focus=None): """ :class:`SimpleTree` that contains a list of all-level-0 Text widgets for each line in content. """ structure = [] # depending on this config setting, we either add individual lines # or the complete context as focusable objects. if settings.get('thread_focus_linewise'): for line in content.splitlines(): structure.append((ANSIText(line, attr, attr_focus, ANSI_BACKGROUND), None)) else: structure.append((ANSIText(content, attr, attr_focus, ANSI_BACKGROUND), None)) SimpleTree.__init__(self, structure) class DictList(SimpleTree): """ :class:`SimpleTree` that displays key-value pairs. The structure will obey the Tree API but will not actually be a tree but a flat list: It contains one top-level node (displaying the k/v pair in Columns) per pair. That is, the root will be the first pair, its sibblings will be the other pairs and first|last_child will always be None. """ def __init__(self, content, key_attr, value_attr, gaps_attr=None): """ :param headerslist: list of key/value pairs to display :type headerslist: list of (str, str) :param key_attr: theming attribute to use for keys :type key_attr: urwid.AttrSpec :param value_attr: theming attribute to use for values :type value_attr: urwid.AttrSpec :param gaps_attr: theming attribute to wrap lines in :type gaps_attr: urwid.AttrSpec """ max_key_len = 1 structure = [] # calc max length of key-string for key, value in content: if len(key) > max_key_len: max_key_len = len(key) for key, value in content: # todo : even/odd keyw = ('fixed', max_key_len + 1, urwid.Text((key_attr, key))) valuew = urwid.Text((value_attr, value)) line = urwid.Columns([keyw, valuew]) if gaps_attr is not None: line = urwid.AttrMap(line, gaps_attr) structure.append((line, None)) SimpleTree.__init__(self, structure) class MessageTree(CollapsibleTree): """ :class:`Tree` that displays contents of a single :class:`alot.db.Message`. Its root node is a :class:`MessageSummaryWidget`, and its child nodes reflect the messages content (parts for headers/attachments etc). Collapsing this message corresponds to showing the summary only. """ def __init__(self, message, odd=True): """ :param message: Message to display :type message: alot.db.Message :param odd: theme summary widget as if this is an odd line (in the message-pile) :type odd: bool """ self._message = message self._odd = odd self.display_source = False self._summaryw = None self._bodytree = None self._sourcetree = None self.display_all_headers = False self._all_headers_tree = None self._default_headers_tree = None self.display_attachments = True self._mimetree = None self._attachments = None self._maintree = SimpleTree(self._assemble_structure(True)) self.display_mimetree = False CollapsibleTree.__init__(self, self._maintree) def get_message(self): return self._message def reassemble(self): self._maintree._treelist = self._assemble_structure() def refresh(self): self._summaryw = None self.reassemble() def debug(self): logging.debug('collapsed %s', self.is_collapsed(self.root)) logging.debug('display_source %s', self.display_source) logging.debug('display_all_headers %s', self.display_all_headers) logging.debug('display_attachements %s', self.display_attachments) logging.debug('display_mimetree %s', self.display_mimetree) logging.debug('AHT %s', str(self._all_headers_tree)) logging.debug('DHT %s', str(self._default_headers_tree)) logging.debug('MAINTREE %s', str(self._maintree._treelist)) def expand(self, pos): """ overload CollapsibleTree.expand method to ensure all parts are present. Initially, only the summary widget is created to avoid reading the messafe file and thus speed up the creation of this object. Once we expand = unfold the message, we need to make sure that body/attachments exist. """ logging.debug("MT expand") if not self._bodytree: self.reassemble() CollapsibleTree.expand(self, pos) def _assemble_structure(self, summary_only=False): if summary_only: return [(self._get_summary(), None)] mainstruct = [] if self.display_source: mainstruct.append((self._get_source(), None)) elif self.display_mimetree: mainstruct.append((self._get_headers(), None)) mainstruct.append((self._get_mimetree(), None)) else: mainstruct.append((self._get_headers(), None)) attachmenttree = self._get_attachments() if attachmenttree is not None: mainstruct.append((attachmenttree, None)) bodytree = self._get_body() if bodytree is not None: mainstruct.append((bodytree, None)) structure = [ (self._get_summary(), mainstruct) ] return structure def collapse_if_matches(self, querystring): """ collapse (and show summary only) if the :class:`alot.db.Message` matches given `querystring` """ self.set_position_collapsed( self.root, self._message.matches(querystring)) def _get_summary(self): if self._summaryw is None: self._summaryw = MessageSummaryWidget( self._message, even=(not self._odd)) return self._summaryw def _get_source(self): if self._sourcetree is None: sourcetxt = self._message.get_email().as_string() sourcetxt = string_sanitize(sourcetxt) att = settings.get_theming_attribute('thread', 'body') att_focus = settings.get_theming_attribute('thread', 'body_focus') self._sourcetree = TextlinesList(sourcetxt, att, att_focus) return self._sourcetree def _get_body(self): if self._bodytree is None: bodytxt = self._message.get_body_text() if bodytxt: att = settings.get_theming_attribute('thread', 'body') att_focus = settings.get_theming_attribute( 'thread', 'body_focus') self._bodytree = TextlinesList(bodytxt, att, att_focus) return self._bodytree def _get_headers(self): if self.display_all_headers is True: if self._all_headers_tree is None: self._all_headers_tree = self.construct_header_pile() ret = self._all_headers_tree else: if self._default_headers_tree is None: headers = settings.get('displayed_headers') self._default_headers_tree = self.construct_header_pile( headers) ret = self._default_headers_tree return ret def _get_attachments(self): if self._attachments is None: alist = [] for a in self._message.get_attachments(): alist.append((AttachmentWidget(a), None)) if alist: self._attachments = SimpleTree(alist) return self._attachments def construct_header_pile(self, headers=None, normalize=True): mail = self._message.get_email() lines = [] if headers is None: # collect all header/value pairs in the order they appear for key, value in mail.items(): dvalue = decode_header(value, normalize=normalize) lines.append((key, dvalue)) else: # only a selection of headers should be displayed. # use order of the `headers` parameter for key in headers: if key in mail: if key.lower() in ['cc', 'bcc', 'to']: values = mail.get_all(key) values = [decode_header( v, normalize=normalize) for v in values] lines.append((key, ', '.join(values))) else: for value in mail.get_all(key): dvalue = decode_header(value, normalize=normalize) lines.append((key, dvalue)) elif key.lower() == 'tags': logging.debug('want tags header') values = [] for t in self._message.get_tags(): tagrep = settings.get_tagstring_representation(t) if t is not tagrep['translated']: t = '%s (%s)' % (tagrep['translated'], t) values.append(t) lines.append((key, ', '.join(values))) # OpenPGP pseudo headers if mail[X_SIGNATURE_MESSAGE_HEADER]: lines.append(('PGP-Signature', mail[X_SIGNATURE_MESSAGE_HEADER])) key_att = settings.get_theming_attribute('thread', 'header_key') value_att = settings.get_theming_attribute('thread', 'header_value') gaps_att = settings.get_theming_attribute('thread', 'header') return DictList(lines, key_att, value_att, gaps_att) def _get_mimetree(self): if self._mimetree is None: tree = self._message.get_mime_tree() tree = self._text_tree_to_widget_tree(tree) tree = SimpleTree([tree]) self._mimetree = ArrowTree(tree) return self._mimetree def _text_tree_to_widget_tree(self, tree): att = settings.get_theming_attribute('thread', 'body') att_focus = settings.get_theming_attribute('thread', 'body_focus') mimepart = tree[1] if isinstance( tree[1], (email.message.EmailMessage, Attachment)) else None label, subtrees = tree label = ANSIText( label, att, att_focus, ANSI_BACKGROUND, mimepart=mimepart) if subtrees is None or mimepart: return label, None else: return label, [self._text_tree_to_widget_tree(s) for s in subtrees] def set_mimepart(self, mimepart): """ Set message widget mime part and invalidate body tree.""" self.get_message().set_mime_part(mimepart) self._bodytree = None class ThreadTree(Tree): """ :class:`Tree` that parses a given :class:`alot.db.Thread` into a tree of :class:`MessageTrees ` that display this threads individual messages. As MessageTreess are *not* urwid widgets themself this is to be used in combination with :class:`NestedTree` only. """ def __init__(self, thread): self._thread = thread self.root = thread.get_toplevel_messages()[0].get_message_id() self._parent_of = {} self._first_child_of = {} self._last_child_of = {} self._next_sibling_of = {} self._prev_sibling_of = {} self._message = {} def accumulate(msg, odd=True): """recursively read msg and its replies""" mid = msg.get_message_id() self._message[mid] = MessageTree(msg, odd) odd = not odd last = None self._first_child_of[mid] = None for reply in thread.get_replies_to(msg): rid = reply.get_message_id() if self._first_child_of[mid] is None: self._first_child_of[mid] = rid self._parent_of[rid] = mid self._prev_sibling_of[rid] = last self._next_sibling_of[last] = rid last = rid odd = accumulate(reply, odd) self._last_child_of[mid] = last return odd last = None for msg in thread.get_toplevel_messages(): mid = msg.get_message_id() self._prev_sibling_of[mid] = last self._next_sibling_of[last] = mid accumulate(msg) last = mid self._next_sibling_of[last] = None # Tree API def __getitem__(self, pos): return self._message.get(pos) def parent_position(self, pos): return self._parent_of.get(pos) def first_child_position(self, pos): return self._first_child_of.get(pos) def last_child_position(self, pos): return self._last_child_of.get(pos) def next_sibling_position(self, pos): return self._next_sibling_of.get(pos) def prev_sibling_position(self, pos): return self._prev_sibling_of.get(pos) @staticmethod def position_of_messagetree(mt): return mt._message.get_message_id() alot-0.11/alot/widgets/utils.py000066400000000000000000000025111466311112200165050ustar00rootroot00000000000000# Copyright (C) Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file """ Utility Widgets not specific to alot """ import urwid class AttrFlipWidget(urwid.AttrMap): """ An AttrMap that can remember attributes to set """ def __init__(self, w, maps, init_map='normal'): self.maps = maps urwid.AttrMap.__init__(self, w, maps[init_map]) def set_map(self, attrstring): self.set_attr_map({None: self.maps[attrstring]}) class DialogBox(urwid.WidgetWrap): def __init__(self, body, title, bodyattr=None, titleattr=None): self.body = urwid.LineBox(body) self.title = urwid.Text(title) if titleattr is not None: self.title = urwid.AttrMap(self.title, titleattr) if bodyattr is not None: self.body = urwid.AttrMap(self.body, bodyattr) box = urwid.Overlay(self.title, self.body, align='center', valign='top', width=len(title), height=None) urwid.WidgetWrap.__init__(self, box) def selectable(self): return self.body.selectable() def keypress(self, size, key): return self.body.keypress(size, key) alot-0.11/docs/000077500000000000000000000000001466311112200133175ustar00rootroot00000000000000alot-0.11/docs/Makefile000066400000000000000000000127051466311112200147640ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build PYTHON = python3 # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source CONFIG_OPTION_TABLES = source/configuration/alotrc_table source/configuration/accounts_table COMMAND_OPTION_TABLES = source/usage/modes/bufferlist.rst \ source/usage/modes/envelope.rst \ source/usage/modes/global.rst \ source/usage/modes/search.rst \ source/usage/modes/taglist.rst \ source/usage/modes/thread.rst .PHONY: html help clean dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" $(COMMAND_OPTION_TABLES): ../alot/commands/__init__.py $(PYTHON) source/generate_commands.py $(CONFIG_OPTION_TABLES): ../alot/defaults/alot.rc.spec $(PYTHON) source/generate_configs.py clean: -$(RM) -rf $(BUILDDIR)/* cleanall: clean -$(RM) -rf $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) html: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." devhelp: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/alot" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/alot" @echo "# devhelp" epub: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." make -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." changes: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(CONFIG_OPTION_TABLES) $(COMMAND_OPTION_TABLES) $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." alot-0.11/docs/source/000077500000000000000000000000001466311112200146175ustar00rootroot00000000000000alot-0.11/docs/source/api/000077500000000000000000000000001466311112200153705ustar00rootroot00000000000000alot-0.11/docs/source/api/commands.rst000066400000000000000000000042021466311112200177210ustar00rootroot00000000000000Commands ========= .. module:: alot.commands User actions are represented by :class:`Command` objects that can then be triggered by :meth:`alot.ui.UI.apply_command`. Command-line strings given by the user via the prompt or key bindings can be translated to :class:`Command` objects using :func:`alot.commands.commandfactory`. Specific actions are defined as subclasses of :class:`Command` and can be registered to a global command pool using the :class:`registerCommand` decorator. .. Note:: that the return value of :func:`commandfactory` depends on the current *mode* the user interface is in. The mode identifier is a string that is uniquely defined by the currently focuses :class:`~alot.buffers.Buffer`. .. note:: The names of the commands available to the user in any given mode do not correspond one-to-one to these subclasses. You can register a Command multiple times under different names, with different forced constructor parameters and so on. See for instance the definition of BufferFocusCommand in 'commands/globals.py':: @registerCommand(MODE, 'bprevious', forced={'offset': -1}, help='focus previous buffer') @registerCommand(MODE, 'bnext', forced={'offset': +1}, help='focus next buffer') class BufferFocusCommand(Command): def __init__(self, buffer=None, offset=0, **kwargs): ... .. autoclass:: Command :members: .. autoclass:: CommandParseError .. autoclass:: CommandArgumentParser .. autofunction:: commandfactory .. autofunction:: lookup_command .. autofunction:: lookup_parser .. autoclass:: registerCommand Globals -------- .. automodule:: alot.commands.globals :members: Envelope -------- .. automodule:: alot.commands.envelope :members: Bufferlist ---------- .. automodule:: alot.commands.bufferlist :members: Search -------- .. automodule:: alot.commands.search :members: Taglist -------- .. automodule:: alot.commands.taglist :members: Namedqueries ------------ .. automodule:: alot.commands.namedqueries :members: Thread -------- .. automodule:: alot.commands.thread :members: alot-0.11/docs/source/api/crypto.rst000066400000000000000000000000711466311112200174400ustar00rootroot00000000000000Crypto ====== .. automodule:: alot.crypto :members: alot-0.11/docs/source/api/database.rst000066400000000000000000000031411466311112200176650ustar00rootroot00000000000000Email Database ============== .. module:: alot.db The python bindings to libnotmuch define :class:`notmuch.Thread` and :class:`notmuch.Message`, which unfortunately are very fragile. Alot defines the wrapper classes :class:`alot.db.Thread` and :class:`alot.db.Message` that use an :class:`manager.DBManager` instance to transparently provide persistent objects. :class:`alot.db.Message` moreover contains convenience methods to extract information about the message like reformated header values, a summary, decoded and interpreted body text and a list of :class:`Attachments `. The central :class:`~alot.ui.UI` instance carries around a :class:`~manager.DBManager` object that is used for any lookups or modifications of the email base. :class:`~manager.DBManager` can directly look up :class:`Thread` and :class:`~alot.db.Message` objects and is able to postpone/cache/retry writing operations in case the Xapian index is locked by another process. Database Manager ----------------- .. autoclass:: alot.db.manager.DBManager :members: Errors ---------- .. module:: alot.db.errors .. autoclass:: DatabaseError :members: .. autoclass:: DatabaseROError :members: .. autoclass:: DatabaseLockedError :members: .. autoclass:: NonexistantObjectError :members: Wrapper ------- .. autoclass:: alot.db.Thread :members: .. autoclass:: alot.db.Message :members: Other Structures ---------------- .. autoclass:: alot.db.attachment.Attachment :members: .. autoclass:: alot.db.envelope.Envelope :members: Utilities --------- .. automodule:: alot.db.utils :members: alot-0.11/docs/source/api/index.rst000066400000000000000000000002601466311112200172270ustar00rootroot00000000000000API and Development ******************* .. module:: alot .. toctree:: :maxdepth: 1 overview database interface settings utils commands crypto alot-0.11/docs/source/api/interface.rst000066400000000000000000000074351466311112200200730ustar00rootroot00000000000000User Interface ================== Alot sets up a widget tree and a :class:`mainloop ` in the constructor of :class:`alot.ui.UI`. The visible area is a :class:`urwid.Frame`, where the footer is used as a status line and the body part displays the currently active :class:`alot.buffers.Buffer`. To be able to bind keystrokes and translate them to :class:`Commands `, keypresses are *not* propagated down the widget tree as is customary in urwid. Instead, the root widget given to urwids mainloop is a custom wrapper (:class:`alot.ui.Inputwrap`) that interprets key presses. A dedicated :class:`~alot.commands.globals.SendKeypressCommand` can be used to trigger key presses to the wrapped root widget and thereby accessing standard urwid behaviour. In order to keep the interface non-blocking and react to events like terminal size changes, alot makes use of asyncio - which allows asynchronous calls without the use of callbacks. Alot makes use of the python 3.5 async/await syntax .. code-block:: python async def greet(ui): # ui is instance of alot.ui.UI name = await ui.prompt('pls enter your name') ui.notify('your name is: ' + name) :class:`UI` - the main component ----------------------------------- .. module:: alot.ui .. autoclass:: UI :members: Buffers ---------- A buffer defines a view to your data. It knows how to render itself, to interpret keypresses and is visible in the "body" part of the widget frame. Different modes are defined by subclasses of the following base class. .. autoclass:: alot.buffers.Buffer :members: Available modes are: ============ ======================================== Mode Buffer Subclass ============ ======================================== search :class:`~alot.buffers.SearchBuffer` thread :class:`~alot.buffers.ThreadBuffer` bufferlist :class:`~alot.buffers.BufferlistBuffer` taglist :class:`~alot.buffers.TagListBuffer` namedqueries :class:`~alot.buffers.NamedQueriesBuffer` envelope :class:`~alot.buffers.EnvelopeBuffer` ============ ======================================== .. automodule:: alot.buffers :members: BufferlistBuffer, EnvelopeBuffer, NamedQueriesBuffer, SearchBuffer, ThreadBuffer, TagListBuffer Widgets -------- What follows is a list of the non-standard urwid widgets used in alot. Some of them respect :doc:`user settings `, themes in particular. utils ````` .. automodule:: alot.widgets.utils :members: globals ``````` .. automodule:: alot.widgets.globals :members: bufferlist `````````` .. automodule:: alot.widgets.bufferlist :members: search `````` .. automodule:: alot.widgets.search :members: thread `````` .. automodule:: alot.widgets.thread :members: Completion ---------- :meth:`alot.ui.UI.prompt` allows tab completion using a :class:`~alot.completion.Completer` object handed as 'completer' parameter. :mod:`alot.completion` defines several subclasses for different occasions like completing email addresses from an :class:`~alot.account.AddressBook`, notmuch tagstrings. Some of these actually build on top of each other; the :class:`~alot.completion.QueryCompleter` for example uses a :class:`~alot.completion.TagsCompleter` internally to allow tagstring completion after "is:" or "tag:" keywords when typing a notmuch querystring. All these classes overide the method :meth:`~alot.completion.Completer.complete`, which for a given string and cursor position in that string returns a list of tuples `(completed_string, new_cursor_position)` that are taken to be the completed values. Note that `completed_string` does not need to have the original string as prefix. :meth:`~alot.completion.Completer.complete` may rise :class:`alot.errors.CompletionError` exceptions. .. automodule:: alot.completion :members: alot-0.11/docs/source/api/overview.rst000066400000000000000000000025701466311112200177740ustar00rootroot00000000000000Overview ======== The main component is :class:`alot.ui.UI`, which provides methods for user input and notifications, sets up the widget tree and maintains the list of active buffers. When you start up alot, :file:`init.py` initializes logging, parses settings and commandline args and instantiates the :class:`UI ` instance of that gets passes around later. From its constructor this instance starts the :mod:`urwid` :class:`mainloop ` that takes over. Apart from the central :class:`UI `, there are two other "managers" responsible for core functionalities, also set up in :file:`init.py`: * :attr:`ui.dbman `: a :class:`DBManager ` to access the email database and * :attr:`alot.settings.settings`: a :class:`SettingsManager ` oo access user settings Every user action, triggered either by key bindings or via the command prompt, is given as commandline string that gets :func:`translated ` to a :class:`Command ` object which is then :meth:`applied `. Different actions are defined as a subclasses of :class:`Command `, which live in :file:`alot/commands/MODE.py`, where MODE is the name of the mode (:class:`Buffer ` type) they are used in. alot-0.11/docs/source/api/settings.rst000066400000000000000000000060001466311112200177560ustar00rootroot00000000000000User Settings ============= .. module:: alot.settings.manager Alot sets up a :class:`SettingsManager` to access user settings defined in different places uniformly. There are four types of user settings: +------------------------------------+----------------------------------+---------------------------------------------+ | what? | location | accessible via | +====================================+==================================+=============================================+ | alot config | :file:`~/.config/alot/config` | :meth:`SettingsManager.get` | | | or given by command option `-c`. | | +------------------------------------+----------------------------------+---------------------------------------------+ | hooks -- user provided python code | :file:`~/.config/alot/hooks.py` | :meth:`SettingsManager.get_hook` | | | or as given by the `hooksfile` | | | | config value | | +------------------------------------+----------------------------------+---------------------------------------------+ | notmuch config | notmuch config file as | :meth:`SettingsManager.get_notmuch_setting` | | | given by command option `-n` | | | | or its default location | | | | described in `notmuch-config(1)` | | +------------------------------------+----------------------------------+---------------------------------------------+ | mailcap -- defines shellcommands | :file:`~/.mailcap` | :meth:`SettingsManager.mailcap_find_match` | | to handle mime types | (:file:`/etc/mailcap`) | | +------------------------------------+----------------------------------+---------------------------------------------+ Settings Manager ---------------- .. autoclass:: SettingsManager :members: Errors ------ .. automodule:: alot.settings.errors :members: Utils ----- .. automodule:: alot.settings.utils :members: Themes ------ .. autoclass:: alot.settings.theme.Theme :members: Accounts -------- .. module:: alot.account .. autoclass:: Address :members: .. autoclass:: Account :members: .. autoclass:: SendmailAccount :members: Addressbooks ------------ .. module:: alot.addressbook .. autoclass:: AddressBook :members: .. module:: alot.addressbook.abook .. autoclass:: AbookAddressBook :members: .. module:: alot.addressbook.external .. autoclass:: ExternalAddressbook alot-0.11/docs/source/api/utils.rst000066400000000000000000000001751466311112200172650ustar00rootroot00000000000000Utils ===== .. currentmodule:: alot.helper .. automodule:: alot.helper :members: .. automodule:: alot.utils :members: alot-0.11/docs/source/conf.py000066400000000000000000000162771466311112200161330ustar00rootroot00000000000000# -*- coding: utf-8 -*- # alot documentation build configuration file import pathlib import tomllib import importlib.metadata pyproject = pathlib.Path(__file__).parent.parent.parent / "pyproject.toml" with pyproject.open("rb") as f: project_data = tomllib.load(f)["project"] # -- General configuration ---------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. needs_sphinx = '1.3' # for autodoc_mock_imports setting below # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] # autodoc tweaks autodoc_mock_imports = [ 'alot.settings.const', 'argparse', 'configobj', 'gpg', 'magic', 'notmuch', 'urwid', 'urwidtrees', 'validate', ] # show classes' docstrings _and_ constructors docstrings/parameters autoclass_content = 'both' # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'alot' copyright = "Copyright (C) Patrick Totzke" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = importlib.metadata.version("alot") # The full version, including alpha/beta/rc tags. release = importlib.metadata.version("alot") # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [ ] # The reST default role (used for this markup: `text`) to use for all documents # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # -- Options for HTML output -------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". html_title = 'Alot User Manual' # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'alotdoc' # -- Options for LaTeX output ------------------------------------------------- # The paper size ('letter' or 'a4'). # latex_paper_size = 'letter' # The font size ('10pt', '11pt' or '12pt'). # latex_font_size = '10pt' # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]) latex_documents = [ ('index', 'alot.tex', 'alot Documentation', 'Patrick Totzke', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Additional stuff for the LaTeX preamble. # latex_preamble = '' # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True autodoc_member_order = 'groupwise' # -- Options for manual page output ------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('manpage', 'alot', project_data["description"], [a["name"] for a in project_data["authors"]], 1), ] # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { 'python': ('https://docs.python.org/', None), 'notmuch': ('https://notmuch.readthedocs.org/en/latest/', None), 'urwid': ('https://urwid.readthedocs.org/en/latest/', None), } alot-0.11/docs/source/configuration/000077500000000000000000000000001466311112200174665ustar00rootroot00000000000000alot-0.11/docs/source/configuration/accounts.rst000066400000000000000000000024151466311112200220410ustar00rootroot00000000000000.. _config.accounts: Accounts ======== In order to be able to send mails, you have to define at least one account subsection in your config: There needs to be a section "accounts", and each subsection, indicated by double square brackets defines an account. Here is an example configuration .. code-block:: ini [accounts] [[work]] realname = Bruce Wayne address = b.wayne@wayneenterprises.com alias_regexp = b.wayne\+.+@wayneenterprises.com gpg_key = D7D6C5AA sendmail_command = msmtp --account=wayne -t sent_box = maildir:///home/bruce/mail/work/Sent # ~, $VAR and ${VAR} expansion also work draft_box = maildir://~/mail/work/Drafts [[secret]] realname = Batman address = batman@batcave.org aliases = batman@batmobile.org, sendmail_command = msmtp --account=batman -t signature = ~/.batman.vcf signature_as_attachment = True .. warning:: Sending mails is only supported via a sendmail shell command for now. If you want to use a sendmail command different from `sendmail -t`, specify it as `sendmail_command`. The following entries are interpreted at the moment: .. include:: accounts_table alot-0.11/docs/source/configuration/accounts_table000066400000000000000000000131261466311112200224020ustar00rootroot00000000000000 .. CAUTION: THIS FILE IS AUTO-GENERATED from the inline comments of specfile defaults/alot.rc.spec. If you want to change its content make your changes to that spec to ensure they woun't be overwritten later. .. _address: .. describe:: address your main email address :type: string .. _alias-regexp: .. describe:: alias_regexp a regex for catching further aliases (like + extensions). :type: string :default: None .. _aliases: .. describe:: aliases used to clear your addresses/ match account when formatting replies :type: string list :default: , .. _case-sensitive-username: .. describe:: case_sensitive_username Whether the server treats the address as case-senstive or case-insensitve (True for the former, False for the latter) .. note:: The vast majority (if not all) SMTP servers in modern use treat usernames as case insenstive, you should only set this if you know that you need it. :type: boolean :default: False .. _draft-box: .. describe:: draft_box where to store draft mails, e.g. `maildir:///home/you/mail/Drafts`, `maildir://$MAILDIR/Drafts` or `maildir://~/mail/Drafts`. You can use mbox, maildir, mh, babyl and mmdf in the protocol part of the URL. .. note:: You will most likely want drafts indexed by notmuch to be able to later access them within alot. This currently only works for maildir containers in a path below your notmuch database path. :type: mail_container :default: None .. _draft-tags: .. describe:: draft_tags list of tags to automatically add to draft messages :type: string list :default: draft .. _encrypt-by-default: .. describe:: encrypt_by_default Alot will try to GPG encrypt outgoing messages by default when this is set to `all` or `trusted`. If set to `all` the message will be encrypted for all recipients for who a key is available in the key ring. If set to `trusted` it will be encrypted to all recipients if a trusted key is available for all recipients (one where the user id for the key is signed with a trusted signature). .. note:: If the message will not be encrypted by default you can still use the :ref:`toggleencrypt `, :ref:`encrypt ` and :ref:`unencrypt ` commands to encrypt it. .. deprecated:: 0.4 The values `True` and `False` are interpreted as `all` and `none` respectively. `0`, `1`, `true`, `True`, `false`, `False`, `yes`, `Yes`, `no`, `No`, will be removed before 1.0, please move to `all`, `none`, or `trusted`. :type: option, one of ['all', 'none', 'trusted', 'True', 'False', 'true', 'false', 'Yes', 'No', 'yes', 'no', '1', '0'] :default: none .. _encrypt-to-self: .. describe:: encrypt_to_self If this is true when encrypting a message it will also be encrypted with the key defined for this account. .. warning:: Before 0.6 this was controlled via gpg.conf. :type: boolean :default: True .. _gpg-key: .. describe:: gpg_key The GPG key ID you want to use with this account. :type: string :default: None .. _message-id-domain: .. describe:: message_id_domain Domain to use in automatically generated Message-ID headers. The default is the local hostname. :type: string :default: None .. _passed-tags: .. describe:: passed_tags list of tags to automatically add to passed messages :type: string list :default: passed .. _realname: .. describe:: realname used to format the (proposed) From-header in outgoing mails :type: string .. _replied-tags: .. describe:: replied_tags list of tags to automatically add to replied messages :type: string list :default: replied .. _sendmail-command: .. describe:: sendmail_command sendmail command. This is the shell command used to send out mails via the sendmail protocol :type: string :default: "sendmail -t" .. _sent-box: .. describe:: sent_box where to store outgoing mails, e.g. `maildir:///home/you/mail/Sent`, `maildir://$MAILDIR/Sent` or `maildir://~/mail/Sent`. You can use mbox, maildir, mh, babyl and mmdf in the protocol part of the URL. .. note:: If you want to add outgoing mails automatically to the notmuch index you must use maildir in a path within your notmuch database path. :type: mail_container :default: None .. _sent-tags: .. describe:: sent_tags list of tags to automatically add to outgoing messages :type: string list :default: sent .. _sign-by-default: .. describe:: sign_by_default Outgoing messages will be GPG signed by default if this is set to True. :type: boolean :default: False .. _signature: .. describe:: signature path to signature file that gets attached to all outgoing mails from this account, optionally renamed to :ref:`signature_filename `. :type: string :default: None .. _signature-as-attachment: .. describe:: signature_as_attachment attach signature file if set to True, append its content (mimetype text) to the body text if set to False. :type: boolean :default: False .. _signature-filename: .. describe:: signature_filename signature file's name as it appears in outgoing mails if :ref:`signature_as_attachment ` is set to True :type: string :default: None alot-0.11/docs/source/configuration/alotrc_table000066400000000000000000000454241466311112200220550ustar00rootroot00000000000000 .. CAUTION: THIS FILE IS AUTO-GENERATED from the inline comments of specfile defaults/alot.rc.spec. If you want to change its content make your changes to that spec to ensure they woun't be overwritten later. .. _ask-subject: .. describe:: ask_subject :type: boolean :default: True .. _attachment-prefix: .. describe:: attachment_prefix directory prefix for downloading attachments :type: string :default: "~" .. _auto-remove-unread: .. describe:: auto_remove_unread automatically remove 'unread' tag when focussing messages in thread mode :type: boolean :default: True .. _auto-replyto-mailinglist: .. describe:: auto_replyto_mailinglist Automatically switch to list reply mode if appropriate :type: boolean :default: False .. _bounce-force-address: .. describe:: bounce_force_address Always use the accounts main address when constructing "Resent-From" headers for bounces. Set this to False to use the address string as received in the original message. :type: boolean :default: False .. _bounce-force-realname: .. describe:: bounce_force_realname Always use the proper realname when constructing "Resent-From" headers for bounces. Set this to False to use the realname string as received in the original message. :type: boolean :default: True .. _bufferclose-focus-offset: .. describe:: bufferclose_focus_offset offset of next focused buffer if the current one gets closed :type: integer :default: -1 .. _bufferlist-statusbar: .. describe:: bufferlist_statusbar Format of the status-bar in bufferlist mode. This is a pair of strings to be left and right aligned in the status-bar that may contain variables: * `{buffer_no}`: index of this buffer in the global buffer list * `{total_messages}`: total numer of messages indexed by notmuch * `{pending_writes}`: number of pending write operations to the index :type: mixed_list :default: [{buffer_no}: bufferlist], {input_queue} total messages: {total_messages} .. _bug-on-exit: .. describe:: bug_on_exit confirm exit :type: boolean :default: False .. _colourmode: .. describe:: colourmode number of colours to use on the terminal :type: option, one of ['1', '16', '256'] :default: 256 .. _complete-matching-abook-only: .. describe:: complete_matching_abook_only in case more than one account has an address book: Set this to True to make tab completion for recipients during compose only look in the abook of the account matching the sender address :type: boolean :default: False .. _compose-ask-tags: .. describe:: compose_ask_tags prompt for initial tags when compose :type: boolean :default: False .. _displayed-headers: .. describe:: displayed_headers headers that get displayed by default :type: string list :default: From, To, Cc, Bcc, Subject .. _edit-headers-blacklist: .. describe:: edit_headers_blacklist see :ref:`edit_headers_whitelist ` :type: string list :default: Content-Type, MIME-Version, References, In-Reply-To .. _edit-headers-whitelist: .. describe:: edit_headers_whitelist Which header fields should be editable in your editor used are those that match the whitelist and don't match the blacklist. in both cases '*' may be used to indicate all fields. :type: string list :default: \*, .. _editor-cmd: .. describe:: editor_cmd editor command if unset, alot will first try the :envvar:`EDITOR` env variable, then :file:`/usr/bin/editor` :type: string :default: None .. _editor-in-thread: .. describe:: editor_in_thread call editor in separate thread. In case your editor doesn't run in the same window as alot, setting true here will make alot non-blocking during edits :type: boolean :default: False .. _editor-spawn: .. describe:: editor_spawn use :ref:`terminal_cmd ` to spawn a new terminal for the editor? equivalent to always providing the `--spawn=yes` parameter to compose/edit commands :type: boolean :default: False .. _editor-writes-encoding: .. describe:: editor_writes_encoding file encoding used by your editor :type: string :default: "UTF-8" .. _envelope-edit-default-alternative: .. describe:: envelope_edit_default_alternative always edit the given body text alternative when editing outgoing messages in envelope mode. alternative, and not the html source, even if that is currently displayed. If unset, html content will be edited unless the current envelope shows the plaintext alternative. :type: option, one of ['plaintext', 'html'] :default: None .. _envelope-headers-blacklist: .. describe:: envelope_headers_blacklist headers that are hidden in envelope buffers by default :type: string list :default: In-Reply-To, References .. _envelope-html2txt: .. describe:: envelope_html2txt Use this command to turn a html message body to plaintext in envelope mode. The command will receive the html on stdin and should produce text on stdout (as `pandoc -f html -t markdown` does for example). :type: string :default: None .. _envelope-statusbar: .. describe:: envelope_statusbar Format of the status-bar in envelope mode. This is a pair of strings to be left and right aligned in the status-bar. Apart from the global variables listed at :ref:`bufferlist_statusbar ` these strings may contain variables: * `{to}`: To-header of the envelope * `{displaypart}`: which body part alternative is currently in view (can be 'plaintext,'src', or 'html') :type: mixed_list :default: [{buffer_no}: envelope ({displaypart})], {input_queue} total messages: {total_messages} .. _envelope-txt2html: .. describe:: envelope_txt2html Use this command to construct a html alternative message body text in envelope mode. If unset, we send only the plaintext part, without html alternative. The command will receive the plaintex on stdin and should produce html on stdout. (as `pandoc -t html` does for example). :type: string :default: None .. _exclude-tags: .. describe:: exclude_tags A list of tags that will be excluded from search results by default. Using an excluded tag in a query will override that exclusion. .. note:: when set, this config setting will overrule the 'search.exclude_tags' in the notmuch config. :type: string list :default: None .. _flush-retry-timeout: .. describe:: flush_retry_timeout timeout in seconds after a failed attempt to writeout the database is repeated. Set to 0 for no retry. :type: integer :default: 5 .. _followup-to: .. describe:: followup_to When one of the recipients of an email is a subscribed mailing list, set the "Mail-Followup-To" header to the list of recipients without yourself :type: boolean :default: False .. _forward-force-address: .. describe:: forward_force_address Always use the accounts main address when constructing "From" headers for forwards. Set this to False to use the address string as received in the original message. :type: boolean :default: False .. _forward-force-realname: .. describe:: forward_force_realname Always use the proper realname when constructing "From" headers for forwards. Set this to False to use the realname string as received in the original message. :type: boolean :default: True .. _forward-subject-prefix: .. describe:: forward_subject_prefix String prepended to subject header on forward only if original subject doesn't start with 'Fwd:' or this prefix :type: string :default: "Fwd: " .. _handle-mouse: .. describe:: handle_mouse enable mouse support - mouse tracking will be handled by urwid .. note:: If this is set to True mouse events are passed from the terminal to urwid/alot. This means that normal text selection in alot will not be possible. Most terminal emulators will still allow you to select text when shift is pressed. :type: boolean :default: False .. _history-size: .. describe:: history_size The number of command line history entries to save .. note:: You can set this to -1 to save *all* entries to disk but the history file might get *very* long. :type: integer :default: 50 .. _honor-followup-to: .. describe:: honor_followup_to When group-reply-ing to an email that has the "Mail-Followup-To" header set, use the content of this header as the new "To" header and leave the "Cc" header empty :type: boolean :default: False .. _hooksfile: .. describe:: hooksfile where to look up hooks :type: string :default: None .. _initial-command: .. describe:: initial_command initial command when none is given as argument: :type: string :default: "search tag:inbox AND NOT tag:killed" .. _input-timeout: .. describe:: input_timeout timeout in (floating point) seconds until partial input is cleared :type: float :default: 1.0 .. _interpret-ansi-background: .. describe:: interpret_ansi_background display background colors set by ANSI character escapes :type: boolean :default: True .. _mailinglists: .. describe:: mailinglists The list of addresses associated to the mailinglists you are subscribed to :type: string list :default: , .. _msg-summary-hides-threadwide-tags: .. describe:: msg_summary_hides_threadwide_tags In a thread buffer, hide from messages summaries tags that are commom to all messages in that thread. :type: boolean :default: True .. _namedqueries-statusbar: .. describe:: namedqueries_statusbar Format of the status-bar in named query list mode. This is a pair of strings to be left and right aligned in the status-bar. These strings may contain variables listed at :ref:`bufferlist_statusbar ` that will be substituted accordingly. :type: mixed_list :default: [{buffer_no}: namedqueries], {query_count} named queries .. _notify-timeout: .. describe:: notify_timeout time in secs to display status messages :type: integer :default: 2 .. _periodic-hook-frequency: .. describe:: periodic_hook_frequency The number of seconds to wait between calls to the loop_hook :type: integer :default: 300 .. _prefer-plaintext: .. describe:: prefer_plaintext prefer plaintext alternatives over html content in multipart/alternative :type: boolean :default: False .. _print-cmd: .. describe:: print_cmd how to print messages: this specifies a shell command used for printing. threads/messages are piped to this command as plain text. muttprint/a2ps works nicely :type: string :default: None .. _prompt-suffix: .. describe:: prompt_suffix Suffix of the prompt used when waiting for user input :type: string :default: ":" .. _quit-on-last-bclose: .. describe:: quit_on_last_bclose shut down when the last buffer gets closed :type: boolean :default: False .. _quote-prefix: .. describe:: quote_prefix String prepended to line when quoting :type: string :default: "> " .. _reply-account-header-priority: .. describe:: reply_account_header_priority The list of headers to match to determine sending account for a reply. Headers are searched in the order in which they are specified here, and the first header containing a match is used. If multiple accounts match in that header, the one defined first in the account block is used. :type: string list :default: From, To, Cc, Envelope-To, X-Envelope-To, Delivered-To .. _reply-force-address: .. describe:: reply_force_address Always use the accounts main address when constructing "From" headers for replies. Set this to False to use the address string as received in the original message. :type: boolean :default: False .. _reply-force-realname: .. describe:: reply_force_realname Always use the proper realname when constructing "From" headers for replies. Set this to False to use the realname string as received in the original message. :type: boolean :default: True .. _reply-subject-prefix: .. describe:: reply_subject_prefix String prepended to subject header on reply only if original subject doesn't start with 'Re:' or this prefix :type: string :default: "Re: " .. _search-statusbar: .. describe:: search_statusbar Format of the status-bar in search mode. This is a pair of strings to be left and right aligned in the status-bar. Apart from the global variables listed at :ref:`bufferlist_statusbar ` these strings may contain variables: * `{querystring}`: search string * `{result_count}`: number of matching messages * `{result_count_positive}`: 's' if result count is greater than 0. :type: mixed_list :default: [{buffer_no}: search] for "{querystring}", {input_queue} {result_count} of {total_messages} messages .. _search-threads-move-last-limit: .. describe:: search_threads_move_last_limit Maximum number of results in a search buffer before 'move last' builds the thread list in reversed order as a heuristic. The resulting order will be different for threads with multiple matching messages. When set to 0, no limit is set (can be very slow in searches that yield thousands of results) :type: integer :default: 200 .. _search-threads-rebuild-limit: .. describe:: search_threads_rebuild_limit maximum amount of threads that will be consumed to try to restore the focus, upon triggering a search buffer rebuild when set to 0, no limit is set (can be very slow in searches that yield thousands of results) :type: integer :default: 0 .. _search-threads-sort-order: .. describe:: search_threads_sort_order default sort order of results in a search :type: option, one of ['oldest_first', 'newest_first', 'message_id', 'unsorted'] :default: newest_first .. _show-statusbar: .. describe:: show_statusbar display status-bar at the bottom of the screen? :type: boolean :default: True .. _tabwidth: .. describe:: tabwidth number of spaces used to replace tab characters :type: integer :default: 8 .. _taglist-statusbar: .. describe:: taglist_statusbar Format of the status-bar in taglist mode. This is a pair of strings to be left and right aligned in the status-bar. These strings may contain variables listed at :ref:`bufferlist_statusbar ` that will be substituted accordingly. :type: mixed_list :default: [{buffer_no}: taglist], {input_queue} total messages: {total_messages} .. _template-dir: .. describe:: template_dir templates directory that contains your message templates. It will be used if you give `compose --template` a filename without a path prefix. :type: string :default: "$XDG_CONFIG_HOME/alot/templates" .. _terminal-cmd: .. describe:: terminal_cmd set terminal command used for spawning shell commands :type: string :default: "x-terminal-emulator -e" .. _theme: .. describe:: theme name of the theme to use :type: string :default: None .. _themes-dir: .. describe:: themes_dir directory containing theme files. :type: string :default: "$XDG_CONFIG_HOME/alot/themes" .. _thread-authors-me: .. describe:: thread_authors_me Word to replace own addresses with. Works in combination with :ref:`thread_authors_replace_me ` :type: string :default: "Me" .. _thread-authors-order-by: .. describe:: thread_authors_order_by When constructing the unique list of thread authors, order by date of author's first or latest message in thread :type: option, one of ['first_message', 'latest_message'] :default: first_message .. _thread-authors-replace-me: .. describe:: thread_authors_replace_me Replace own email addresses with "me" in author lists Uses own addresses and aliases in all configured accounts. :type: boolean :default: True .. _thread-focus-linewise: .. describe:: thread_focus_linewise Split message body linewise and allows to (move) the focus to each individual line. Setting this to False will result in one potentially big text widget for the whole message body. :type: boolean :default: True .. _thread-indent-replies: .. describe:: thread_indent_replies number of characters used to indent replies relative to original messages in thread mode :type: integer :default: 2 .. _thread-statusbar: .. describe:: thread_statusbar Format of the status-bar in thread mode. This is a pair of strings to be left and right aligned in the status-bar. Apart from the global variables listed at :ref:`bufferlist_statusbar ` these strings may contain variables: * `{tid}`: thread id * `{subject}`: subject line of the thread * `{authors}`: abbreviated authors string for this thread * `{message_count}`: number of contained messages * `{thread_tags}`: displays all tags present in the current thread. * `{intersection_tags}`: displays tags common to all messages in the current thread. * `{mimetype}`: content type of the mime part displayed in the focused message. :type: mixed_list :default: [{buffer_no}: thread] {subject}, [{mimetype}] {input_queue} total messages: {total_messages} .. _thread-subject: .. describe:: thread_subject What should be considered to be "the thread subject". Valid values are: * 'notmuch' (the default), will use the thread subject from notmuch, which depends on the selected sorting method * 'oldest' will always use the subject of the oldest message in the thread as the thread subject :type: option, one of ['oldest', 'notmuch'] :default: notmuch .. _thread-unfold-matching: .. describe:: thread_unfold_matching Unfold messages matching the query. If not set, will unfold all messages matching search buffer query. :type: string :default: None .. _timestamp-format: .. describe:: timestamp_format timestamp format in `strftime format syntax `_ :type: string :default: None .. _user-agent: .. describe:: user_agent value of the User-Agent header used for outgoing mails. setting this to the empty string will cause alot to omit the header all together. The string '{version}' will be replaced by the version string of the running instance. :type: string :default: "alot/{version}" alot-0.11/docs/source/configuration/config_options.rst000066400000000000000000000014371466311112200232450ustar00rootroot00000000000000.. _config.options: Configuration Options ===================== The following lists all available config options with their type and default values. The type of an option is used to validate a given value. For instance, if the type says "boolean" you may only provide "True" or "False" as values in your config file, otherwise alot will complain on startup. Strings *may* be quoted but do not need to be. .. include:: alotrc_table Notmuch options --------------- The following lists the notmuch options that alot reads. .. _search.exclude_tags: .. describe:: search.exclude_tags A list of tags that will be excluded from search results by default. Using an excluded tag in a query will override that exclusion. :type: semicolon separated list :default: empty list alot-0.11/docs/source/configuration/contacts_completion.rst000066400000000000000000000100651466311112200242710ustar00rootroot00000000000000.. _config.contacts_completion: Contacts Completion =================== For each :ref:`account ` you can define an address book by providing a subsection named `abook`. Crucially, this section needs an option `type` that specifies the type of the address book. The only types supported at the moment are "shellcommand" and "abook". Both respect the `ignorecase` option which defaults to `True` and results in case insensitive lookups. .. describe:: shellcommand Address books of this type use a shell command in combination with a regular expression to look up contacts. The value of `command` will be called with the search prefix as only argument for lookups. Its output is searched for email-name pairs using the regular expression given as `regexp`, which must include named groups "email" and "name" to match the email address and realname parts respectively. See below for an example that uses `abook `_ .. sourcecode:: ini [accounts] [[youraccount]] # ... [[[abook]]] type = shellcommand command = abook --mutt-query regexp = '^(?P[^@]+@[^\t]+)\t+(?P[^\t]+)' ignorecase = True See `here `_ for alternative lookup commands. The few others I have tested so far are: `goobook `_ for cached google contacts lookups. Works with the above default regexp .. code-block:: ini command = goobook query regexp = '^(?P[^@]+@[^\t]+)\t+(?P[^\t]+)' `nottoomuch-addresses `_ completes contacts found in the notmuch index: .. code-block:: ini command = nottoomuch-addresses.sh regexp = \"(?P.+)\"\s*<(?P.*.+?@.+?)> `notmuch-abook `_ completes contacts found in database of notmuch-abook: .. code-block:: ini command = notmuch_abook.py lookup regexp = ^((?P[^(\\s+\<)]*)\s+<)?(?P[^@]+?@[^>]+)>?$ `notmuch address `_ Since version `0.19`, notmuch itself offers a subcommand `address`, that returns email addresses found in the notmuch index. Combined with the `date:` syntax to query for mails within a certain timeframe, this allows to search contacts that you've sent emails to (output all addresses from the `To`, `Cc` and `Bcc` headers): .. code-block:: ini command = 'notmuch address --format=json --output=recipients date:1Y.. AND from:my@address.org' regexp = '\[?{"name": "(?P.*)", "address": "(?P.+)", "name-addr": ".*"}[,\]]?' shellcommand_external_filtering = False If you want to search for senders in the `From` header (which should be must faster according to `notmuch address docs `_), then use the following command: .. code-block:: ini command = 'notmuch address --format=json date:1Y..' `notmuch-addlookup `_ If you have the 'notmuch-addrlookup' tool installed you can hook it to 'alot' with the following: .. code-block:: ini command = 'notmuch-addrlookup ' regexp = '(?P.*).*<(?P.+)>' Don't hesitate to send me your custom `regexp` values to list them here. .. describe:: abook Address books of this type directly parse `abooks `_ contact files. You may specify a path using the "abook_contacts_file" option, which defaults to :file:`~/.abook/addressbook`. To use the default path, simply do this: .. code-block:: ini [accounts] [[youraccount]] # ... [[[abook]]] type = abook alot-0.11/docs/source/configuration/hooks.rst000066400000000000000000000157771466311112200213640ustar00rootroot00000000000000.. _config.hooks: Hooks ===== Hooks are python callables that live in a module specified by `hooksfile` in the config. .. versionadded:: 0.11 in newer versions of alot, `hooksfile` does *not* default to :file:`~/.config/alot/hooks.py` but instead needs to be explicitly set if you want to use hooks. Pre/Post Command Hooks ---------------------- For every :ref:`COMMAND ` in mode :ref:`MODE `, the callables :func:`pre_MODE_COMMAND` and :func:`post_MODE_COMMAND` -- if defined -- will be called before and after the command is applied respectively. In addition callables :func:`pre_global_COMMAND` and :func:`post_global_COMMAND` can be used. They will be called if no specific hook function for a mode is defined. The signature for the pre-`send` hook in envelope mode for example looks like this: .. py:function:: pre_envelope_send(ui=None, dbm=None, cmd=None) :param ui: the main user interface :type ui: :class:`alot.ui.UI` :param dbm: a database manager :type dbm: :class:`alot.db.manager.DBManager` :param cmd: the Command instance that is being called :type cmd: :class:`alot.commands.Command` Consider this pre-hook for the exit command, that logs a personalized goodbye message:: import logging from alot.settings.const import settings def pre_global_exit(**kwargs): accounts = settings.get_accounts() if accounts: logging.info('goodbye, %s!' % accounts[0].realname) else: logging.info('goodbye!') Other Hooks ----------- Apart from command pre- and posthooks, the following hooks will be interpreted: .. py:function:: reply_prefix(realname, address, timestamp[, message=None, ui= None, dbm=None]) Is used to reformat the first indented line in a reply message. This defaults to 'Quoting %s (%s)\n' % (realname, timestamp)' unless this hook is defined :param realname: name or the original sender :type realname: str :param address: address of the sender :type address: str :param timestamp: value of the Date header of the replied message :type timestamp: :obj:`datetime.datetime` :param message: message object attached to reply :type message: :obj:`email.Message` :rtype: string .. py:function:: forward_prefix(realname, address, timestamp[, message=None, ui= None, dbm=None]) Is used to reformat the first indented line in a inline forwarded message. This defaults to 'Forwarded message from %s (%s)\n' % (realname, timestamp)' if this hook is undefined :param realname: name or the original sender :type realname: str :param address: address of the sender :type address: str :param timestamp: value of the Date header of the replied message :type timestamp: :obj:`datetime.datetime` :param message: message object being forwarded :type message: :obj:`email.Message` :rtype: string .. _pre-edit-translate: .. py:function:: pre_edit_translate(text[, ui= None, dbm=None]) Used to manipulate a message's text *before* the editor is called. The text might also contain some header lines, depending on the settings :ref:`edit_headers_whitelist ` and :ref:`edit_header_blacklist `. :param text: text representation of mail as displayed in the interface and as sent to the editor :type text: str :rtype: str .. py:function:: post_edit_translate(text[, ui= None, dbm=None]) used to manipulate a message's text *after* the editor is called, also see :ref:`pre_edit_translate ` :param text: text representation of mail as displayed in the interface and as sent to the editor :type text: str :rtype: str .. py:function:: text_quote(message) used to transform a message into a quoted one :param message: message to be quoted :type message: str :rtype: str .. py:function:: timestamp_format(timestamp) represents given timestamp as string :param timestamp: timestamp to represent :type timestamp: `datetime` :rtype: str .. py:function:: touch_external_cmdlist(cmd, shell=shell, spawn=spawn, thread=thread) used to change external commands according to given flags shortly before they are called. :param cmd: command to be called :type cmd: list of str :param shell: is this to be interpreted by the shell? :type shell: bool :param spawn: should be spawned in new terminal/environment :type spawn: bool :param threads: should be called in new thread :type thread: bool :returns: triple of amended command list, shell and thread flags :rtype: list of str, bool, bool .. py:function:: reply_subject(subject) used to reformat the subject header on reply :param subject: subject to reformat :type subject: str :rtype: str .. py:function:: forward_subject(subject) used to reformat the subject header on forward :param subject: subject to reformat :type subject: str :rtype: str .. py:function:: pre_buffer_open(ui= None, dbm=None, buf=buf) run before a new buffer is opened :param buf: buffer to open :type buf: alot.buffer.Buffer .. py:function:: post_buffer_open(ui=None, dbm=None, buf=buf) run after a new buffer is opened :param buf: buffer to open :type buf: alot.buffer.Buffer .. py:function:: pre_buffer_close(ui=None, dbm=None, buf=buf) run before a buffer is closed :param buf: buffer to open :type buf: alot.buffer.Buffer .. py:function:: post_buffer_close(ui=None, dbm=None, buf=buf, success=success) run after a buffer is closed :param buf: buffer to open :type buf: alot.buffer.Buffer :param success: true if successfully closed buffer :type success: boolean .. py:function:: pre_buffer_focus(ui=None, dbm=None, buf=buf) run before a buffer is focused :param buf: buffer to open :type buf: alot.buffer.Buffer .. py:function:: post_buffer_focus(ui=None, dbm=None, buf=buf, success=success) run after a buffer is focused :param buf: buffer to open :type buf: alot.buffer.Buffer :param success: true if successfully focused buffer :type success: boolean .. py:function:: exit() run just before the program exits .. py:function:: sanitize_attachment_filename(filename=None, prefix='', suffix='') returns `prefix` and `suffix` for a sanitized filename to use while opening an attachment. The `prefix` and `suffix` are used to open a file named `prefix` + `XXXXXX` + `suffix` in a temporary directory. :param filename: filename provided in the email (can be None) :type filename: str or None :param prefix: prefix string as found on mailcap :type prefix: str :param suffix: suffix string as found on mailcap :type suffix: str :returns: tuple of `prefix` and `suffix` :rtype: (str, str) .. py:function:: loop_hook(ui=None) Run on a period controlled by :ref:`_periodic_hook_frequency ` :param ui: the main user interface :type ui: :class:`alot.ui.UI` alot-0.11/docs/source/configuration/index.rst000066400000000000000000000011121466311112200213220ustar00rootroot00000000000000.. _configuration: ************* Configuration ************* Alot reads a config file in "INI" syntax: It consists of key-value pairs that use "=" as separator and '#' is comment-prefixes. Sections and subsections are defined using square brackets. The default location for the config file is :file:`~/.config/alot/config`. All configs are optional, but if you want to send mails you need to specify at least one :ref:`account ` in your config. .. toctree:: :maxdepth: 2 config_options accounts contacts_completion key_bindings hooks theming alot-0.11/docs/source/configuration/key_bindings.rst000066400000000000000000000045731466311112200226760ustar00rootroot00000000000000.. _config.key_bindings: Key Bindings ============ If you want to bind a command to a key you can do so by adding the pair to the `[bindings]` section. This will introduce a *global* binding, that works in all modes. To make a binding specific to a mode you have to add the pair under the subsection named like the mode. For instance, if you want to bind `T` to open a new search for threads tagged with 'todo', and be able to toggle this tag in search mode, you'd add this to your config .. sourcecode:: ini [bindings] T = search tag:todo [[search]] t = toggletags todo .. _modes: Known modes are: * bufferlist * envelope * namedqueries * search * taglist * thread Have a look at `the urwid User Input documentation `_ on how key strings are formatted. .. _config.key-bingings.defaults: Default bindings ---------------- User-defined bindings are combined with the default bindings listed below. .. literalinclude:: ../../../alot/defaults/default.bindings :language: ini In prompts the following hardcoded bindings are available. =========================== ======== Key Function =========================== ======== Ctrl-f/b Moves the curser one character to the right/left Alt-f/b Shift-right/left Moves the cursor one word to the right/left Ctrl-a/e Moves the curser to the beginning/end of the line Ctrl-d Deletes the character under the cursor Alt-d Deletes everything from the cursor to the end of the current or next word Alt-Delete/Backspace Ctrl-w Deletes everything from the cursor to the beginning of the current or previous word Ctrl-k Deletes everything from the cursor to the end of the line Ctrl-u Deletes everything from the cursor to the beginning of the line =========================== ======== Overwriting defaults -------------------- To disable a global binding you can redefine it in your config to point to an empty command string. For example, to add a new global binding for key `a`, which is bound to `toggletags inbox` in search mode by default, you can remap it as follows. .. sourcecode:: ini [bindings] a = NEW GLOBAL COMMAND [[search]] a = If you omit the last two lines, `a` will still be bound to the default binding in search mode. alot-0.11/docs/source/configuration/theming.rst000066400000000000000000000233041466311112200216550ustar00rootroot00000000000000.. _config.theming: Theming ======= Alot can be run in 1, 16 or 256 colour mode. The requested mode is determined by the command-line parameter `-C` or read from option `colourmode` config value. The default is 256, which scales down depending on how many colours your terminal supports. Most parts of the user interface can be individually coloured to your liking. To make it easier to switch between or share different such themes, they are defined in separate files (see below for the exact format). To specify the theme to use, set the :ref:`theme ` config option to the name of a theme-file. A file by that name will be looked up in the path given by the :ref:`themes_dir ` config setting which defaults to $XDG_CONFIG_HOME/alot/themes, and :file:`~/.config/alot/themes/`, if XDG_CONFIG_HOME is empty or not set. If the themes_dir is not present then the contents of $XDG_DATA_DIRS/alot/themes will be tried in order. This defaults to :file:`/usr/local/share/alot/themes` and :file:`/usr/share/alot/themes`, in that order. These locations are meant to be used by distro packages to put themes in. .. _config.theming.themefiles: Theme Files ----------- contain a section for each :ref:`MODE ` plus "help" for the bindings-help overlay and "global" for globally used themables like footer, prompt etc. Each such section defines colour :ref:`attributes ` for the parts that can be themed. The names of the themables should be self-explanatory. Have a look at the default theme file at :file:`alot/defaults/default.theme` and the config spec :file:`alot/defaults/default.theme` for the exact format. .. _config.theming.attributes: Colour Attributes ----------------- Attributes are *sextuples* of `urwid Attribute strings `__ that specify foreground and background for mono, 16 and 256-colour modes respectively. For mono-mode only the flags `blink`, `standup`, `underline` and `bold` are available, 16c mode supports these in combination with the colour names:: brown dark red dark magenta dark blue dark cyan dark green yellow light red light magenta light blue light cyan light green black dark gray light gray white In high-colour mode, you may use the above plus grayscales `g0` to `g100` and colour codes given as `#` followed by three hex values. See `here `__ and `here `__ for more details on the interpreted values. A colour picker that makes choosing colours easy can be found in :file:`alot/extra/colour_picker.py`. As an example, check the setting below that makes the footer line appear as underlined bold red text on a bright green background: .. sourcecode:: ini [[global]] #name mono fg mono bg 16c fg 16c bg 256c fg 256c bg # | | | | | | # v v v v v v footer = 'bold,underline', '', 'light red, bold, underline', 'light green', 'light red, bold, underline', '#8f6' Search mode threadlines ------------------------- The subsection '[[threadline]]' of the '[search]' section in :ref:`Theme Files ` determines how to present a thread: here, :ref:`attributes ` 'normal' and 'focus' provide fallback/spacer themes and 'parts' is a (string) list of displayed subwidgets. Possible part strings are: * authors * content * date * mailcount * subject * tags For every listed part there must be a subsection with the same name, defining :normal: :ref:`attribute ` used for this part if unfocussed :focus: :ref:`attribute ` used for this part if focussed :width: tuple indicating the width of the part. This is either `('fit', min, max)` to force the widget to be at least `min` and at most `max` characters wide, or `('weight', n)` which makes it share remaining space with other 'weight' parts. :alignment: how to place the content string if the widget space is larger. This must be one of 'right', 'left' or 'center'. Dynamic theming of thread lines based on query matching ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ To highlight some thread lines (use different attributes than the defaults found in the '[[threadline]]' section), one can define sections with prefix 'threadline'. Each one of those can redefine any part of the structure outlined above, the rest defaults to values defined in '[[threadline]]'. The section used to theme a particular thread is the first one (in file-order) that matches the criteria defined by its 'query' and 'tagged_with' values: * If 'query' is defined, the thread must match that querystring. * If 'tagged_with' is defined, is value (string list) must be a subset of the accumulated tags of all messages in the thread. .. note:: that 'tagged_with = A,B' is different from 'query = "is:A AND is:B"': the latter will match only if the thread contains a single message that is both tagged with A and B. Moreover, note that if both query and tagged_with is undefined, this section will always match and thus overwrite the defaults. The example below shows how to highlight unread threads: The date-part will be bold red if the thread has unread messages and flagged messages and just bold if the thread has unread but no flagged messages: .. sourcecode:: ini [search] # default threadline [[threadline]] normal = 'default','default','default','default','#6d6','default' focus = 'standout','default','light gray','dark gray','white','#68a' parts = date,mailcount,tags,authors,subject [[[date]]] normal = 'default','default','light gray','default','g58','default' focus = 'standout','default','light gray','dark gray','g89','#68a' width = 'fit',10,10 # ... # highlight threads containing unread and flagged messages [[threadline-flagged-unread]] tagged_with = 'unread','flagged' [[[date]]] normal = 'default','default','light red,bold','default','light red,bold','default' # highlight threads containing unread messages [[threadline-unread]] query = 'is:unread' [[[date]]] normal = 'default','default','light gray,bold','default','g58,bold','default' .. _config.theming.tags: Tagstring Formatting -------------------- One can specify how a particular tagstring is displayed throughout the interface. To use this feature, add a section `[tags]` to you alot config (not the theme file) and for each tag you want to customize, add a subsection named after the tag. Such a subsection may define :normal: :ref:`attribute ` used if unfocussed :focus: :ref:`attribute ` used if focussed :translated: fixed string representation for this tag. The tag can be hidden from view, if the key `translated` is set to '', the empty string. :translation: a pair of strings that define a regular substitution to compute the string representation on the fly using `re.sub`. This only really makes sense if one uses a regular expression to match more than one tagstring (see below). The following will make alot display the "todo" tag as "TODO" in white on red. .. sourcecode:: ini [tags] [[todo]] normal = '','', 'white','light red', 'white','#d66' translated = TODO Utf-8 symbols are welcome here, see e.g. https://panmental.de/symbols/info.htm for some fancy symbols. I personally display my maildir flags like this: .. sourcecode:: ini [tags] [[flagged]] translated = ⚑ normal = '','','light red','','light red','' focus = '','','light red','','light red','' [[unread]] translated = ✉ [[replied]] translated = ⏎ [[encrypted]] translated = ⚷ You may use regular expressions in the tagstring subsections to theme multiple tagstrings at once (first match wins). If you do so, you can use the `translation` option to specify a string substitution that will rename a matching tagstring. `translation` takes a comma separated *pair* of strings that will be fed to :func:`re.sub`. For instance, to theme all your `nmbug`_ tagstrings and especially colour tag `notmuch::bug` red, do the following: .. sourcecode:: ini [[notmuch::bug]] translated = 'nm:bug' normal = "", "", "light red, bold", "light blue", "light red, bold", "#88d" [[notmuch::.*]] translation = 'notmuch::(.*)','nm:\1' normal = "", "", "white", "light blue", "#fff", "#88d" .. _nmbug: https://notmuchmail.org/nmbug/ ANSI escape codes -------------------- Alot's message display will interpret `ANSI escape codes `__ in the "body" text to be displayed. You can use this feature to let your HTML renderer interpret colours from html mails and translate them to ANSI escapes. For instance, `elinks `__ can do this for you if you use the following entry in your `~/.mailcap`: .. sourcecode:: bash text/html; elinks -force-html -dump -dump-color-mode 3 -dump-charset utf8 -eval 'set document.codepage.assume = "%{charset}"' %s; copiousoutput Test your theme --------------- Use the script in `extra/theme_test.py` to test your theme file. You should test with different terminal configurations, take black-on-white and white-on-black themes in account. alot-0.11/docs/source/description.rst000066400000000000000000000003331466311112200176730ustar00rootroot00000000000000Alot is a terminal-based mail user agent for the notmuch mail system. It features a modular and command prompt driven interface to provide a full MUA experience as an alternative to the Emacs mode shipped with notmuch. alot-0.11/docs/source/faq.rst000066400000000000000000000115321466311112200161220ustar00rootroot00000000000000Frequently Asked Questions ************************** .. _faq_1: .. rubric:: 1. Help! I don't see `text/html` content! You need to set up a mailcap entry to declare an external renderer for `text/html`. Try `w3m `_ and put the following into your :file:`~/.mailcap`:: text/html; w3m -dump -o document_charset=%{charset} '%s'; nametemplate=%s.html; copiousoutput On more recent versions of w3m, links can be parsed and appended with reference numbers:: text/html; w3m -dump -o document_charset=%{charset} -o display_link_number=1 '%s'; nametemplate=%s.html; copiousoutput Most `text based browsers `_ have a dump mode that can be used here. .. _faq_2: .. rubric:: 2. Why reinvent the wheel? Why not extend an existing MUA to work nicely with notmuch? alot makes use of existing solutions where possible: It does not fetch, send or edit mails; it lets `notmuch `_ handle your mailindex and uses a `toolkit `_ to render its display. You are responsible for `automatic initial tagging `_. This said, there are few CLI MUAs that could be easily and naturally adapted to using notmuch. Rebuilding an interface from scratch using `friendly and extensible tools `_ seemed easier and more promising. Update: see `neomutt `_ for a fork of mutt. .. _faq_3: .. rubric:: 3. What's with the snotty name? It's not meant to be presumptuous. I like the dichotomy; I like to picture the look on someone's face who reads the :mailheader:`User-Agent` header "notmuch/alot"; I like cookies; I like `this comic strip `_. .. _faq_4: .. rubric:: 4. I want feature X! Me too! Feel free to file a new or comment on existing `issues `_ if you don't want/have the time/know how to implement it yourself. Be verbose as to how it should look or work when it's finished and give it some thought how you think we should implement it. We'll discuss it from there. .. _faq_5: .. rubric:: 5. Why are the default key bindings so counter-intuitive? Be aware that the bindings for all modes are :ref:`fully configurable `. That said, I choose the bindings to be natural for me. I use `vim `_ and `pentadactyl `_ a lot. However, I'd be interested in discussing the defaults. If you think your bindings are more intuitive or better suited as defaults for some reason, don't hesitate to send me your config. The same holds for the theme settings you use. Tell me. Let's improve the defaults. .. _faq_6: .. rubric:: 6. Why are you doing $THIS not $THAT way? Lazyness and Ignorance: In most cases I simply did not or still don't know a better solution. I try to outsource as much as I can to well established libraries and be it only to avoid having to read rfc's. But there are lots of tasks I implemented myself, possibly overlooking a ready made and available solution. Twisted is such a feature-rich but gray area in my mind for example. If you think you know how to improve the current implementation let me know! The few exceptions to above stated rule are the following: * The modules cmd and cmd2, that handle all sorts of convenience around command objects hate urwid: They are painfully strongly coupled to user in/output via stdin and out. * `notmuch reply` is not used to format reply messages because 1. it is not offered by notmuch's library but is a feature of the CLI. This means we would have to call the notmuch binary, something that is avoided where possible. 2. As there is no `notmuch forward` equivalent, this (very similar) functionality would have to be re-implemented anyway. .. _faq_7: .. rubric:: 7. I thought alot ran on Python 2? It used to. When we made the transition to Python 3 we didn't maintain Python 2 support. If you still need Python 2 support the 0.7 release is your best bet. .. _faq_8: .. rubric:: 8. I thought alot used twisted? It used to. After we switched to python 3 we decided to switch to asyncio, which reduced the number of dependencies we have. Twisted is an especially heavy dependency, when we only used their async mechanisms, and not any of the other goodness that twisted has to offer. .. _faq_9: .. rubric:: 9. How do I search within the content of a mail? Alot does not yet have this feature built-in. However, you can pipe a mail to your preferred pager and do it from there. This can be done using the :ref:`pipeto ` command (the default shortcut is '|') in thread buffers:: pipeto --format=decoded less Using less, you search with '/' and save with 's'. See :ref:`here ` or `help pipeto` for help on this command. alot-0.11/docs/source/generate_commands.py000077500000000000000000000102341466311112200206470ustar00rootroot00000000000000import argparse import sys import os HERE = os.path.dirname(__file__) sys.path.insert(0, os.path.join(HERE, '..', '..')) from alot.commands import * from alot.commands import COMMANDS import alot.buffers from alot.utils.argparse import BooleanAction NOTE = ".. CAUTION: THIS FILE IS AUTO-GENERATED!\n\n\n" class HF(argparse.HelpFormatter): def _metavar_formatter(self, action, default_metavar): if action.metavar is not None: result = action.metavar else: result = default_metavar def format(tuple_size): if isinstance(result, tuple): return result else: return (result, ) * tuple_size return format def rstify_parser(parser): parser.formatter_class = HF formatter = parser._get_formatter() out = "" # usage usage = formatter._format_usage(None, parser._actions, parser._mutually_exclusive_groups, '').strip() usage = usage.replace('--', '---') # section header out += '.. describe:: %s\n\n' % parser.prog # description out += ' ' * 4 + parser.description out += '\n\n' if len(parser._positionals._group_actions) == 1: out += " argument\n" a = parser._positionals._group_actions[0] out += ' '*8 + str(parser._positionals._group_actions[0].help) if a.choices: out += "; valid choices are: %s" % ','.join(['\'%s\'' % s for s in a.choices]) if a.default: out += " (defaults to: '%s')" % a.default out += '\n\n' elif len(parser._positionals._group_actions) > 1: out += " positional arguments\n" for index, a in enumerate(parser._positionals._group_actions): out += " %s: %s" % (index, a.help) if a.choices: out += "; valid choices are: %s" % ','.join( ['\'%s\'' % s for s in a.choices]) if a.default: out += " (defaults to: '%s')" % a.default out += '\n' out += '\n\n' if parser._optionals._group_actions: out += " optional arguments\n" for a in parser._optionals._group_actions: switches = [s.replace('--', '---') for s in a.option_strings] out += " :%s: %s" % (', '.join(switches), a.help) if a.choices and not isinstance(a, BooleanAction): out += "; valid choices are: %s" % ','.join(['\'%s\'' % s for s in a.choices]) if a.default: out += " (defaults to: '%s')" % a.default out += '\n' out += '\n' return out def get_mode_docs(): docs = {} b = alot.buffers.Buffer for entry in alot.buffers.__dict__.values(): if isinstance(entry, type): if issubclass(entry, b) and not entry == b: docs[entry.modename] = entry.__doc__.strip() return docs if __name__ == "__main__": modes = [] for mode, modecommands in sorted(COMMANDS.items()): modefilename = mode+'.rst' modefile = open(os.path.join(HERE, 'usage', 'modes', modefilename), 'w') modefile.write(NOTE) if mode != 'global': modes.append(mode) header = 'Commands in \'%s\' mode' % mode modefile.write('%s\n%s\n' % (header, '-' * len(header))) modefile.write('The following commands are available in %s mode:' '\n\n' % mode) else: header = 'Global commands' modefile.write('%s\n%s\n' % (header, '-' * len(header))) modefile.write('The following commands are available globally:' '\n\n') for cmdstring, struct in sorted(modecommands.items()): cls, parser, forced_args = struct labelline = '.. _cmd.%s.%s:\n\n' % (mode, cmdstring.replace('_', '-')) modefile.write(labelline) modefile.write(rstify_parser(parser)) modefile.close() alot-0.11/docs/source/generate_configs.py000077500000000000000000000047511466311112200205050ustar00rootroot00000000000000import sys import os import re from configobj import ConfigObj from validate import Validator HERE = os.path.dirname(__file__) sys.path.insert(0, os.path.join(HERE, '..', '..')) from alot.commands import COMMANDS NOTE = """ .. CAUTION: THIS FILE IS AUTO-GENERATED from the inline comments of specfile %s. If you want to change its content make your changes to that spec to ensure they woun't be overwritten later. """ def rewrite_entries(config, path, specpath, sec=None): file = open(path, 'w') file.write(NOTE % specpath) if sec is None: sec = config for entry in sorted(sec.scalars): v = Validator() etype, eargs, _, default = v._parse_check(sec[entry]) if default is not None: default = config._quote(default) if etype == 'gpg_key_hint': etype = 'string' description = '\n.. _%s:\n' % entry.replace('_', '-') description += '\n.. describe:: %s\n\n' % entry comments = [sec.inline_comments[entry]] + sec.comments[entry] for c in comments: if c: description += ' ' * 4 + re.sub(r'^\s*#', '', c) description = description.rstrip(' ') + '\n' if etype == 'option': description += '\n :type: option, one of %s\n' % eargs else: if etype == 'force_list': etype = 'string list' description += '\n :type: %s\n' % etype if default is not None: default = default.replace('*', '\\*') if etype in ['string', 'string_list', 'gpg_key_hint'] and \ default != 'None': description += ' :default: "%s"\n\n' % (default) else: description += ' :default: %s\n\n' % (default) file.write(description) file.close() if __name__ == "__main__": specpath = os.path.join(HERE, '..', '..', 'alot', 'defaults', 'alot.rc.spec') config = ConfigObj(None, configspec=specpath, stringify=False, list_values=False) config.validate(Validator()) alotrc_table_file = os.path.join(HERE, 'configuration', 'alotrc_table') rewrite_entries(config.configspec, alotrc_table_file, 'defaults/alot.rc.spec') rewrite_entries(config, os.path.join(HERE, 'configuration', 'accounts_table'), 'defaults/alot.rc.spec', sec=config.configspec['accounts']['__many__']) alot-0.11/docs/source/index.rst000066400000000000000000000003231466311112200164560ustar00rootroot00000000000000Alot ==== .. include:: description.rst .. toctree:: :maxdepth: 2 :numbered: installation usage/index configuration/index api/index faq .. toctree:: :hidden: manpage description alot-0.11/docs/source/installation.rst000066400000000000000000000056141466311112200200600ustar00rootroot00000000000000Installation ************ These days, alot can be installed directly using your favourite package manager. On a recent Debian (-derived) systems for instance, just do `sudo apt install alot` and you're done. .. note:: Alot uses `mailcap `_ to look up mime-handler for inline rendering and opening of attachments. To avoid surprises you should at least have an inline renderer (copiousoutput) set up for `text/html` in your :file:`~/.mailcap`:: text/html; w3m -dump -o document_charset=%{charset} '%s'; nametemplate=%s.html; copiousoutput On more recent versions of w3m, links can be parsed and appended with reference numbers:: text/html; w3m -dump -o document_charset=%{charset} -o display_link_number=1 '%s'; nametemplate=%s.html; copiousoutput See the manpage :manpage:`mailcap(5)` or :rfc:`1524` for more details on your mailcap setup. Manual installation ------------------- Alot depends on recent versions of notmuch and urwid. Note that due to restrictions on argparse and subprocess, you need to run *python ≥ 3.5* (see :ref:`faq `). A full list of dependencies is below: * `libmagic and python bindings `_, ≥ `5.04` * `configobj `_, ≥ `4.7.0` * `libnotmuch `_ and it's python bindings, ≥ `0.30` * `urwid `_ toolkit, ≥ `1.3.0` * `urwidtrees `_, ≥ `1.0.3` * `gpg `_ and it's python bindings, > `1.10.0` * `twisted `_, ≥ `18.4.0` On Debian/Ubuntu these are packaged as:: python3-setuptools python3-magic python3-configobj python3-notmuch python3-urwid python3-urwidtrees python3-gpg python3-twisted python3-dev swig On Fedora/Redhat these are packaged as:: python-setuptools python-magic python-configobj python-notmuch python-urwid python-urwidtrees python-gpg python-twisted To set up and install the latest development version:: git clone https://github.com/pazz/alot python3 -m venv dev-venv . dev-venv/bin/activate pip install -e . or you can install the development version into :file:`~/.local/bin`:: pip install --user . Make sure :file:`~/.local/bin` is in your :envvar:`PATH`. For system-wide installation omit the `--user` flag and call with the respective permissions. Generating the Docs ------------------- This requires `sphinx `_, ≥ `1.3` to be installed. To generate the documentation from the source directory simply do:: make -C docs html A man page can be generated using:: make -C docs man Both will end up in their respective subfolders in :file:`docs/build`. In order to remove the command docs and automatically re-generate them from inline docstrings, use the make target `cleanall`, as in:: make -C docs cleanall html alot-0.11/docs/source/manpage.rst000066400000000000000000000005711466311112200167640ustar00rootroot00000000000000Manpage ======= Synopsis -------- .. include:: usage/synopsis.rst Description ----------- .. include:: description.rst Options ------- .. include:: usage/cli_options.rst Commands -------- .. include:: usage/cli_commands.rst Usage ----- .. include:: usage/first_steps.rst UNIX Signals ------------ .. include:: usage/signals.rst See Also -------- :manpage:`notmuch(1)` alot-0.11/docs/source/usage/000077500000000000000000000000001466311112200157235ustar00rootroot00000000000000alot-0.11/docs/source/usage/cli_commands.rst000066400000000000000000000005741466311112200211130ustar00rootroot00000000000000search start in a search buffer using the query string provided as parameter (see :manpage:`notmuch-search-terms(7)`) compose compose a new message bufferlist start with only a bufferlist buffer open taglist start with only a taglist buffer open namedqueries start with list of named queries pyshell start the interactive python shell inside alot alot-0.11/docs/source/usage/cli_options.rst000066400000000000000000000015031466311112200207760ustar00rootroot00000000000000-r, --read-only open notmuch database in read-only mode -c FILENAME, --config=FILENAME configuration file (default: ~/.config/alot/config) -n FILENAME, --notmuch-config=FILENAME notmuch configuration file (default: see notmuch-config(1)) -C COLOURS, --colour-mode=COLOURS number of colours to use on the terminal; must be 1, 16 or 256 (default: configuration option `colourmode` or 256) -p PATH, --mailindex-path=PATH path to notmuch index -d LEVEL, --debug-level=LEVEL debug level; must be one of debug, info, warning or error (default: info) -l FILENAME, --logfile=FILENAME log file (default: /dev/null) -h, --help display help and exit -v, --version output version information and exit alot-0.11/docs/source/usage/commands.rst000066400000000000000000000021301466311112200202520ustar00rootroot00000000000000Commands ======== Alot interprets user input as command line strings given via its prompt or :ref:`bound to keys ` in the config. Command lines are semi-colon separated command strings, each of which starts with a command name and possibly followed by arguments. See the sections below for which commands are available in which (UI) mode. `global` commands are available independently of the mode. :doc:`modes/global` globally available commands :doc:`modes/bufferlist` commands while listing active buffers :doc:`modes/envelope` commands during message composition :doc:`modes/namedqueries` commands while listing all named queries from the notmuch database :doc:`modes/search` commands available when showing thread search results :doc:`modes/taglist` commands while listing all tagstrings present in the notmuch database :doc:`modes/thread` commands available while displaying a thread .. toctree:: :maxdepth: 2 :hidden: modes/global modes/bufferlist modes/envelope modes/namedqueries modes/search modes/taglist modes/thread alot-0.11/docs/source/usage/crypto.rst000066400000000000000000000047271466311112200200070ustar00rootroot00000000000000Cryptography ============ Alot has built in support for constructing signed and/or encrypted mails according to PGP/MIME (:rfc:`3156`, :rfc:`3156`) via gnupg. It does however rely on a running `gpg-agent` to handle password entries. .. note:: You need to have `gpg-agent` running to use GPG with alot! `gpg-agent` will handle passphrase entry in a secure and configurable way, and it will cache your passphrase for some time so you don’t have to enter it over and over again. For details on how to set this up we refer to `gnupg's manual `_. .. rubric:: Signing outgoing emails You can use the commands :ref:`sign `, :ref:`unsign ` and :ref:`togglesign ` in envelope mode to determine if you want this mail signed and if so, which key to use. To specify the key to use you may pass a hint string as argument to the `sign` or `togglesign` command. This hint would typically be a fingerprint or an email address associated (by gnupg) with a key. Signing (and hence passwd entry) will be done at most once shortly before a mail is sent. In case no key is specified, alot will leave the selection of a suitable key to gnupg so you can influence that by setting the `default-key` option in :file:`~/.gnupg/gpg.conf` accordingly. You can set the default to-sign bit and the key to use for each :ref:`account ` individually using the options :ref:`sign_by_default ` and :ref:`gpg_key `. .. rubric:: Encrypt outgoing emails You can use the commands :ref:`encrypt `, :ref:`unencrypt ` and and :ref:`toggleencrypt ` and in envelope mode to ask alot to encrypt the mail before sending. The :ref:`encrypt ` command accepts an optional hint string as argument to determine the key of the recipient. You can set the default to-encrypt bit for each :ref:`account ` individually using the option :ref:`encrypt_by_default `. .. note:: If you want to access encrypt mail later it is useful to add yourself to the list of recipients when encrypting with gpg (not the recipients whom mail is actually send to). The simplest way to do this is to use the `encrypt-to` option in the :file:`~/.gnupg/gpg.conf`. But you might have to specify the correct encryption subkey otherwise gpg seems to throw an error. alot-0.11/docs/source/usage/first_steps.rst000066400000000000000000000011451466311112200210230ustar00rootroot00000000000000The arrow keys, `page-up/down`, `j`, `k` and `Space` can be used to move the focus. `Escape` cancels prompts and `Enter` selects. Hit `:` at any time and type in commands to the prompt. The interface shows one buffer at a time, you can use `Tab` and `Shift-Tab` to switch between them, close the current buffer with `d` and list them all with `;`. The buffer type or *mode* (displayed at the bottom left) determines which prompt commands are available. Usage information on any command can be listed by typing `help YOURCOMMAND` to the prompt. The keybindings for the current mode are listed upon pressing `?`. alot-0.11/docs/source/usage/index.rst000066400000000000000000000012741466311112200175700ustar00rootroot00000000000000***** Usage ***** Command-Line Invocation ======================= .. rubric:: Synopsis .. include:: synopsis.rst .. rubric:: Options .. include:: cli_options.rst .. rubric:: Commands alot can be invoked with an optional subcommand from the command line. Those have their own parameters (see e.g. `alot search --help`). The following commands are available. .. include:: cli_commands.rst UNIX Signals ============ .. include:: signals.rst First Steps in the UI ===================== .. _usage.first_steps: .. include:: first_steps.rst .. _usage.commands: .. include:: commands.rst .. _usage.crypto: .. include:: crypto.rst .. toctree:: :hidden: first_steps synopsis crypto alot-0.11/docs/source/usage/modes/000077500000000000000000000000001466311112200170325ustar00rootroot00000000000000alot-0.11/docs/source/usage/modes/bufferlist.rst000066400000000000000000000004671466311112200217400ustar00rootroot00000000000000.. CAUTION: THIS FILE IS AUTO-GENERATED! Commands in 'bufferlist' mode ----------------------------- The following commands are available in bufferlist mode: .. _cmd.bufferlist.close: .. describe:: close close focussed buffer .. _cmd.bufferlist.open: .. describe:: open focus selected buffer alot-0.11/docs/source/usage/modes/envelope.rst000066400000000000000000000067351466311112200214140ustar00rootroot00000000000000.. CAUTION: THIS FILE IS AUTO-GENERATED! Commands in 'envelope' mode --------------------------- The following commands are available in envelope mode: .. _cmd.envelope.attach: .. describe:: attach attach files to the mail argument file(s) to attach (accepts wildcards) .. _cmd.envelope.detach: .. describe:: detach remove attachments from current envelope argument name of the attachment to remove (accepts wildcards) .. _cmd.envelope.display: .. describe:: display change which body alternative to display argument part to show .. _cmd.envelope.edit: .. describe:: edit edit mail optional arguments :---spawn: spawn editor in new terminal :---refocus: refocus envelope after editing (defaults to: 'True') :---part: which alternative to edit ("html" or "plaintext"); valid choices are: 'html','plaintext' .. _cmd.envelope.encrypt: .. describe:: encrypt request encryption of message before sendout argument keyid of the key to encrypt with optional arguments :---trusted: only add trusted keys .. _cmd.envelope.html2txt: .. describe:: html2txt convert html to plaintext alternative argument converter command to use .. _cmd.envelope.refine: .. describe:: refine prompt to change the value of a header argument header to refine .. _cmd.envelope.removehtml: .. describe:: removehtml remove HTML alternative from the envelope .. _cmd.envelope.retag: .. describe:: retag set message tags argument comma separated list of tags .. _cmd.envelope.rmencrypt: .. describe:: rmencrypt do not encrypt to given recipient key argument keyid of the key to encrypt with .. _cmd.envelope.save: .. describe:: save save draft .. _cmd.envelope.send: .. describe:: send send mail .. _cmd.envelope.set: .. describe:: set set header value positional arguments 0: header to refine 1: value optional arguments :---append: keep previous values .. _cmd.envelope.sign: .. describe:: sign mark mail to be signed before sending argument which key id to use .. _cmd.envelope.tag: .. describe:: tag add tags to message argument comma separated list of tags .. _cmd.envelope.toggleencrypt: .. describe:: toggleencrypt toggle if message should be encrypted before sendout argument keyid of the key to encrypt with optional arguments :---trusted: only add trusted keys .. _cmd.envelope.toggleheaders: .. describe:: toggleheaders toggle display of all headers .. _cmd.envelope.togglesign: .. describe:: togglesign toggle sign status argument which key id to use .. _cmd.envelope.toggletags: .. describe:: toggletags flip presence of tags on message argument comma separated list of tags .. _cmd.envelope.txt2html: .. describe:: txt2html convert plaintext to html alternative argument converter command to use .. _cmd.envelope.unencrypt: .. describe:: unencrypt remove request to encrypt message before sending .. _cmd.envelope.unset: .. describe:: unset remove header field argument header to refine .. _cmd.envelope.unsign: .. describe:: unsign mark mail not to be signed before sending .. _cmd.envelope.untag: .. describe:: untag remove tags from message argument comma separated list of tags alot-0.11/docs/source/usage/modes/global.rst000066400000000000000000000075201466311112200210300ustar00rootroot00000000000000.. CAUTION: THIS FILE IS AUTO-GENERATED! Global commands --------------- The following commands are available globally: .. _cmd.global.bclose: .. describe:: bclose close a buffer optional arguments :---redraw: redraw current buffer after command has finished :---force: never ask for confirmation .. _cmd.global.bnext: .. describe:: bnext focus next buffer .. _cmd.global.bprevious: .. describe:: bprevious focus previous buffer .. _cmd.global.buffer: .. describe:: buffer focus buffer with given index argument buffer index to focus .. _cmd.global.bufferlist: .. describe:: bufferlist open a list of active buffers .. _cmd.global.call: .. describe:: call execute python code argument python command string to call .. _cmd.global.compose: .. describe:: compose compose a new email argument None optional arguments :---sender: sender :---template: path to a template message file :---tags: comma-separated list of tags to apply to message :---subject: subject line :---to: recipients :---cc: copy to :---bcc: blind copy to :---attach: attach files :---omit_signature: do not add signature :---spawn: spawn editor in new terminal .. _cmd.global.confirmsequence: .. describe:: confirmsequence prompt to confirm a sequence of commands argument Additional message to prompt .. _cmd.global.exit: .. describe:: exit shut down cleanly .. _cmd.global.flush: .. describe:: flush flush write operations or retry until committed .. _cmd.global.help: .. describe:: help display help for a command (use 'bindings' to display all keybindings interpreted in current mode) argument command or 'bindings' .. _cmd.global.move: .. describe:: move move focus in current buffer argument up, down, [half]page up, [half]page down, first, last .. _cmd.global.namedqueries: .. describe:: namedqueries opens named queries buffer .. _cmd.global.prompt: .. describe:: prompt prompts for commandline and interprets it upon select argument initial content .. _cmd.global.pyshell: .. describe:: pyshell open an interactive python shell for introspection .. _cmd.global.refresh: .. describe:: refresh refresh the current buffer .. _cmd.global.reload: .. describe:: reload reload all configuration files .. _cmd.global.removequery: .. describe:: removequery removes a "named query" from the database argument alias to remove optional arguments :---no-flush: postpone a writeout to the index (defaults to: 'True') .. _cmd.global.repeat: .. describe:: repeat repeat the command executed last time .. _cmd.global.savequery: .. describe:: savequery store query string as a "named query" in the database positional arguments 0: alias to use for query string 1: query string to store optional arguments :---no-flush: postpone a writeout to the index (defaults to: 'True') .. _cmd.global.search: .. describe:: search open a new search buffer. Search obeys the notmuch :ref:`search.exclude_tags ` setting. argument search string optional arguments :---sort: sort order; valid choices are: 'oldest_first','newest_first','message_id','unsorted' .. _cmd.global.shellescape: .. describe:: shellescape run external command argument command line to execute optional arguments :---spawn: run in terminal window :---thread: run in separate thread :---refocus: refocus current buffer after command has finished .. _cmd.global.taglist: .. describe:: taglist opens taglist buffer optional arguments :---tags: tags to display alot-0.11/docs/source/usage/modes/namedqueries.rst000066400000000000000000000005031466311112200222440ustar00rootroot00000000000000.. CAUTION: THIS FILE IS AUTO-GENERATED! Commands in 'namedqueries' mode ------------------------------- The following commands are available in namedqueries mode: .. _cmd.namedqueries.select: .. describe:: select search for messages with selected query argument additional filter to apply to query alot-0.11/docs/source/usage/modes/search.rst000066400000000000000000000052331466311112200210340ustar00rootroot00000000000000.. CAUTION: THIS FILE IS AUTO-GENERATED! Commands in 'search' mode ------------------------- The following commands are available in search mode: .. _cmd.search.move: .. describe:: move move focus in search buffer argument last .. _cmd.search.refine: .. describe:: refine refine query argument search string optional arguments :---sort: sort order; valid choices are: 'oldest_first','newest_first','message_id','unsorted' .. _cmd.search.refineprompt: .. describe:: refineprompt prompt to change this buffers querystring .. _cmd.search.retag: .. describe:: retag set tags to all messages in the selected thread argument comma separated list of tags optional arguments :---no-flush: postpone a writeout to the index (defaults to: 'True') :---all: retag all messages that match the current query .. _cmd.search.retagprompt: .. describe:: retagprompt prompt to retag selected thread's or message's tags .. _cmd.search.savequery: .. describe:: savequery store query string as a "named query" in the database. This falls back to the current search query in search buffers. positional arguments 0: alias to use for query string 1: query string to store optional arguments :---no-flush: postpone a writeout to the index (defaults to: 'True') .. _cmd.search.select: .. describe:: select open thread in a new buffer optional arguments :---all-folded: do not unfold matching messages .. _cmd.search.sort: .. describe:: sort set sort order argument sort order; valid choices are: 'oldest_first','newest_first','message_id','unsorted' .. _cmd.search.tag: .. describe:: tag add tags to all messages in the selected thread argument comma separated list of tags optional arguments :---no-flush: postpone a writeout to the index (defaults to: 'True') :---all: tag all messages that match the current search query .. _cmd.search.toggletags: .. describe:: toggletags flip presence of tags on the selected thread: a tag is considered present and will be removed if at least one message in this thread is tagged with it argument comma separated list of tags optional arguments :---no-flush: postpone a writeout to the index (defaults to: 'True') .. _cmd.search.untag: .. describe:: untag remove tags from all messages in the selected thread argument comma separated list of tags optional arguments :---no-flush: postpone a writeout to the index (defaults to: 'True') :---all: untag all messages that match the current query alot-0.11/docs/source/usage/modes/taglist.rst000066400000000000000000000003631466311112200212350ustar00rootroot00000000000000.. CAUTION: THIS FILE IS AUTO-GENERATED! Commands in 'taglist' mode -------------------------- The following commands are available in taglist mode: .. _cmd.taglist.select: .. describe:: select search for messages with selected tag alot-0.11/docs/source/usage/modes/thread.rst000066400000000000000000000113061466311112200210340ustar00rootroot00000000000000.. CAUTION: THIS FILE IS AUTO-GENERATED! Commands in 'thread' mode ------------------------- The following commands are available in thread mode: .. _cmd.thread.bounce: .. describe:: bounce directly re-send selected message .. _cmd.thread.editnew: .. describe:: editnew edit message in as new optional arguments :---spawn: open editor in new window .. _cmd.thread.fold: .. describe:: fold fold message(s) argument query used to filter messages to affect .. _cmd.thread.forward: .. describe:: forward forward message optional arguments :---attach: attach original mail :---spawn: open editor in new window .. _cmd.thread.indent: .. describe:: indent change message/reply indentation argument None .. _cmd.thread.move: .. describe:: move move focus in current buffer argument up, down, [half]page up, [half]page down, first, last, parent, first reply, last reply, next sibling, previous sibling, next, previous, next unfolded, previous unfolded, next NOTMUCH_QUERY, previous NOTMUCH_QUERY .. _cmd.thread.pipeto: .. describe:: pipeto pipe message(s) to stdin of a shellcommand argument shellcommand to pipe to optional arguments :---all: pass all messages :---format: output format; valid choices are: 'raw','decoded','id','filepath' (defaults to: 'raw') :---separately: call command once for each message :---background: don't stop the interface :---add_tags: add 'Tags' header to the message :---shell: let the shell interpret the command :---notify_stdout: display cmd's stdout as notification .. _cmd.thread.print: .. describe:: print print message(s) optional arguments :---all: print all messages :---raw: pass raw mail string :---separately: call print command once for each message :---add_tags: add 'Tags' header to the message .. _cmd.thread.remove: .. describe:: remove remove message(s) from the index optional arguments :---all: remove whole thread .. _cmd.thread.reply: .. describe:: reply reply to message optional arguments :---all: reply to all :---list: reply to list :---spawn: open editor in new window .. _cmd.thread.retag: .. describe:: retag set message(s) tags. argument comma separated list of tags optional arguments :---all: tag all messages in thread :---no-flush: postpone a writeout to the index (defaults to: 'True') .. _cmd.thread.retagprompt: .. describe:: retagprompt prompt to retag selected thread's or message's tags .. _cmd.thread.save: .. describe:: save save attachment(s) argument path to save to optional arguments :---all: save all attachments .. _cmd.thread.select: .. describe:: select select focussed element: - if it is a message summary, toggle visibility of the message; - if it is an attachment line, open the attachment - if it is a mimepart, toggle visibility of the mimepart .. _cmd.thread.tag: .. describe:: tag add tags to message(s) argument comma separated list of tags optional arguments :---all: tag all messages in thread :---no-flush: postpone a writeout to the index (defaults to: 'True') .. _cmd.thread.toggleheaders: .. describe:: toggleheaders display all headers argument query used to filter messages to affect .. _cmd.thread.togglemimepart: .. describe:: togglemimepart switch between html and plain text message argument query used to filter messages to affect .. _cmd.thread.togglemimetree: .. describe:: togglemimetree disply mime tree of the message argument query used to filter messages to affect .. _cmd.thread.togglesource: .. describe:: togglesource display message source argument query used to filter messages to affect .. _cmd.thread.toggletags: .. describe:: toggletags flip presence of tags on message(s) argument comma separated list of tags optional arguments :---all: tag all messages in thread :---no-flush: postpone a writeout to the index (defaults to: 'True') .. _cmd.thread.unfold: .. describe:: unfold unfold message(s) argument query used to filter messages to affect .. _cmd.thread.untag: .. describe:: untag remove tags from message(s) argument comma separated list of tags optional arguments :---all: tag all messages in thread :---no-flush: postpone a writeout to the index (defaults to: 'True') alot-0.11/docs/source/usage/signals.rst000066400000000000000000000001211466311112200201070ustar00rootroot00000000000000SIGUSR1 Refreshes the current buffer. SIGINT Shuts down the user interface. alot-0.11/docs/source/usage/synopsis.rst000066400000000000000000000000401466311112200203360ustar00rootroot00000000000000alot [options ...] [subcommand] alot-0.11/extra/000077500000000000000000000000001466311112200135125ustar00rootroot00000000000000alot-0.11/extra/alot-mailto.desktop000066400000000000000000000003451466311112200173310ustar00rootroot00000000000000[Desktop Entry] Name=alot Categories=Office;Email;ConsoleOnly; GenericName=Email client Comment=Terminal MUA using notmuch mail Exec=alot compose %u Terminal=true Type=Application MimeType=x-scheme-handler/mailto; NoDisplay=true alot-0.11/extra/alot.desktop000066400000000000000000000002511466311112200160420ustar00rootroot00000000000000[Desktop Entry] Name=alot Categories=Office;Email;ConsoleOnly; GenericName=Email client Comment=Terminal MUA using notmuch mail Exec=alot Terminal=true Type=Application alot-0.11/extra/colour_picker.py000077500000000000000000000230211466311112200167250ustar00rootroot00000000000000#!/usr/bin/env python3 # # COLOUR PICKER. # This is a lightly modified version of urwids palette_test.py example # script as found at # https://raw.github.com/wardi/urwid/master/examples/palette_test.py # # This version simply omits resetting the screens default colour palette, # and therefore displays the colour attributes as alot would render them in # your terminal. # # Urwid Palette Test. Showing off highcolor support # Copyright (C) 2004-2009 Ian Ward # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library 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 # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Urwid web site: https://urwid.org/ """ Palette test. Shows the available foreground and background settings in monochrome, 16 color, 88 color and 256 color modes. """ import re import urwid import urwid.raw_display CHART_256 = """ brown__ dark_red_ dark_magenta_ dark_blue_ dark_cyan_ dark_green_ yellow_ light_red light_magenta light_blue light_cyan light_green #00f#06f#08f#0af#0df#0ff black_______ dark_gray___ #60f#00d#06d#08d#0ad#0dd#0fd light_gray__ white_______ #80f#60d#00a#06a#08a#0aa#0da#0fa #a0f#80d#60a#008#068#088#0a8#0d8#0f8 #d0f#a0d#80d#608#006#066#086#0a6#0d6#0f6 #f0f#d0d#a0a#808#606#000#060#080#0a0#0d0#0f0#0f6#0f8#0fa#0fd#0ff #f0d#d0a#a08#806#600#660#680#6a0#6d0#6f0#6f6#6f8#6fa#6fd#6ff#0df #f0a#d08#a06#800#860#880#8a0#8d0#8f0#8f6#8f8#8fa#8fd#8ff#6df#0af #f08#d06#a00#a60#a80#aa0#ad0#af0#af6#af8#afa#afd#aff#8df#6af#08f #f06#d00#d60#d80#da0#dd0#df0#df6#df8#dfa#dfd#dff#adf#8af#68f#06f #f00#f60#f80#fa0#fd0#ff0#ff6#ff8#ffa#ffd#fff#ddf#aaf#88f#66f#00f #fd0#fd6#fd8#fda#fdd#fdf#daf#a8f#86f#60f #66d#68d#6ad#6dd #fa0#fa6#fa8#faa#fad#faf#d8f#a6f#80f #86d#66a#68a#6aa#6da #f80#f86#f88#f8a#f8d#f8f#d6f#a0f #a6d#86a#668#688#6a8#6d8 #f60#f66#f68#f6a#f6d#f6f#d0f #d6d#a6a#868#666#686#6a6#6d6#6d8#6da#6dd #f00#f06#f08#f0a#f0d#f0f #d6a#a68#866#886#8a6#8d6#8d8#8da#8dd#6ad #d68#a66#a86#aa6#ad6#ad8#ada#add#8ad#68d #d66#d86#da6#dd6#dd8#dda#ddd#aad#88d#66d g78_g82_g85_g89_g93_g100 #da6#da8#daa#dad#a8d#86d g52_g58_g62_g66_g70_g74_ #88a#8aa #d86#d88#d8a#d8d#a6d g27_g31_g35_g38_g42_g46_g50_ #a8a#888#8a8#8aa #d66#d68#d6a#d6d g0__g3__g7__g11_g15_g19_g23_ #a88#aa8#aaa#88a #a88#a8a """ CHART_88 = """ brown__ dark_red_ dark_magenta_ dark_blue_ dark_cyan_ dark_green_ yellow_ light_red light_magenta light_blue light_cyan light_green #00f#08f#0cf#0ff black_______ dark_gray___ #80f#00c#08c#0cc#0fc light_gray__ white_______ #c0f#80c#008#088#0c8#0f8 #f0f#c0c#808#000#080#0c0#0f0#0f8#0fc#0ff #88c#8cc #f0c#c08#800#880#8c0#8f0#8f8#8fc#8ff#0cf #c8c#888#8c8#8cc #f08#c00#c80#cc0#cf0#cf8#cfc#cff#8cf#08f #c88#cc8#ccc#88c #f00#f80#fc0#ff0#ff8#ffc#fff#ccf#88f#00f #c88#c8c #fc0#fc8#fcc#fcf#c8f#80f #f80#f88#f8c#f8f#c0f g62_g74_g82_g89_g100 #f00#f08#f0c#f0f g0__g19_g35_g46_g52 """ CHART_16 = """ brown__ dark_red_ dark_magenta_ dark_blue_ dark_cyan_ dark_green_ yellow_ light_red light_magenta light_blue light_cyan light_green black_______ dark_gray___ light_gray__ white_______ """ ATTR_RE = re.compile("(?P[ \n]*)(?P[^ \n]+)") SHORT_ATTR = 4 # length of short high-colour descriptions which may # be packed one after the next def parse_chart(chart, convert): """ Convert string chart into text markup with the correct attributes. chart -- palette chart as a string convert -- function that converts a single palette entry to an (attr, text) tuple, or None if no match is found """ out = [] for match in re.finditer(ATTR_RE, chart): if match.group('whitespace'): out.append(match.group('whitespace')) entry = match.group('entry') entry = entry.replace("_", " ") while entry: # try the first four characters attrtext = convert(entry[:SHORT_ATTR]) if attrtext: elen = SHORT_ATTR entry = entry[SHORT_ATTR:].strip() else: # try the whole thing attrtext = convert(entry.strip()) assert attrtext, "Invalid palette entry: %r" % entry elen = len(entry) entry = "" attr, text = attrtext out.append((attr, text.ljust(elen))) return out def foreground_chart(chart, background, colors): """ Create text markup for a foreground colour chart chart -- palette chart as string background -- colour to use for background of chart colors -- number of colors (88 or 256) """ def convert_foreground(entry): try: attr = urwid.AttrSpec(entry, background, colors) except urwid.AttrSpecError: return None return attr, entry return parse_chart(chart, convert_foreground) def background_chart(chart, foreground, colors): """ Create text markup for a background colour chart chart -- palette chart as string foreground -- colour to use for foreground of chart colors -- number of colors (88 or 256) This will remap 8 <= colour < 16 to high-colour versions in the hopes of greater compatibility """ def convert_background(entry): try: attr = urwid.AttrSpec(foreground, entry, colors) except urwid.AttrSpecError: return None # fix 8 <= colour < 16 if colors > 16 and attr.background_basic and \ attr.background_number >= 8: # use high-colour with same number entry = 'h%d' % attr.background_number attr = urwid.AttrSpec(foreground, entry, colors) return attr, entry return parse_chart(chart, convert_background) def main(): palette = [ ('header', 'black,underline', 'light gray', 'standout,underline', 'black,underline', '#88a'), ('panel', 'light gray', 'dark blue', '', '#ffd', '#00a'), ('focus', 'light gray', 'dark cyan', 'standout', '#ff8', '#806'), ] screen = urwid.raw_display.Screen() screen.register_palette(palette) lb = urwid.SimpleListWalker([]) chart_offset = None # offset of chart in lb list mode_radio_buttons = [] chart_radio_buttons = [] def fcs(widget): # wrap widgets that can take focus return urwid.AttrMap(widget, None, 'focus') def set_mode(colors, is_foreground_chart): # set terminal mode and redraw chart screen.set_terminal_properties(colors) chart_fn = (background_chart, foreground_chart)[is_foreground_chart] if colors == 1: lb[chart_offset] = urwid.Divider() else: chart = {16: CHART_16, 88: CHART_88, 256: CHART_256}[colors] txt = chart_fn(chart, 'default', colors) lb[chart_offset] = urwid.Text(txt, wrap='clip') def on_mode_change(rb, state, colors): # if this radio button is checked if state: is_foreground_chart = chart_radio_buttons[0].state set_mode(colors, is_foreground_chart) def mode_rb(text, colors, state=False): # mode radio buttons rb = urwid.RadioButton(mode_radio_buttons, text, state) urwid.connect_signal(rb, 'change', on_mode_change, colors) return fcs(rb) def on_chart_change(rb, state): # handle foreground check box state change set_mode(screen.colors, state) def click_exit(button): raise urwid.ExitMainLoop() lb.extend([ urwid.AttrMap(urwid.Text("Urwid Palette Test"), 'header'), urwid.AttrMap(urwid.Columns([ urwid.Pile([ mode_rb("Monochrome", 1), mode_rb("16-Color", 16, True), mode_rb("88-Color", 88), mode_rb("256-Color", 256)]), urwid.Pile([ fcs(urwid.RadioButton(chart_radio_buttons, "Foreground Colors", True, on_chart_change)), fcs(urwid.RadioButton(chart_radio_buttons, "Background Colors")), urwid.Divider(), fcs(urwid.Button("Exit", click_exit)), ]), ]), 'panel') ]) chart_offset = len(lb) lb.extend([ urwid.Divider() # placeholder for the chart ]) set_mode(16, True) # displays the chart def unhandled_input(key): if key in ('Q', 'q', 'esc'): raise urwid.ExitMainLoop() urwid.MainLoop(urwid.ListBox(lb), screen=screen, unhandled_input=unhandled_input).run() if __name__ == "__main__": main() alot-0.11/extra/completion/000077500000000000000000000000001466311112200156635ustar00rootroot00000000000000alot-0.11/extra/completion/alot-completion.zsh000066400000000000000000000061101466311112200215150ustar00rootroot00000000000000#compdef alot # ZSH completion for `alot`, Shamelessly copied from notmuch's zsh completion file # Copyright © 2009 Ingmar Vanhassel # Copyright © 2012-2017 Patrick Totzke _alot_subcommands() { local -a alot_subcommands alot_subcommands=( 'search:search for messages matching the search terms, display matching threads as results' 'compose:compose a new message' 'bufferlist:show a list of open alot buffers' 'taglist:list all tags in the database' 'pyshell:start the interactive python shell inside alot' ) _describe -t command 'command' alot_subcommands } _alot_search() { _arguments -s : \ '--sort=[sort results]:sorting:((newest_first\:"reverse chronological order" oldest_first\:"chronological order" message_id\:"lexicographically by Message Id"))' } _alot_account_emails() { python3 - ${XDG_CONFIG_HOME:-~/.config}/alot/config <<-'EOF' import configobj, sys config = configobj.ConfigObj(infile=sys.argv[1], encoding="UTF8") accounts = config.get("accounts", {}) addresses = [accounts[k].get('address') for k in accounts if type(accounts[k]) is configobj.Section] print(" ".join(addresses), end="") EOF } _alot_compose() { _arguments -s : \ '--attach=[Attach files]:attach:_files -/' \ '--bcc=[Blind Carbon Copy header]:Recipient (Bcc header):_email_addresses' \ '--cc=[Carbon Copy header]:Recipient (Cc header):_email_addresses' \ '--omit_signature[do not add signature]' \ "--sender=[From header]:Sender account:($(_alot_account_emails))" \ '--subject=[Subject header]' \ '--template=[template file to use]' \ '--to=[To header]:Recipient (To header):_email_addresses' \ } _alot() { local state local ret=1 # Complete global options. Set $state to "command" or "options" in order to # do further completion. _arguments \ '(- *)'{-h,--help}'[show the help message]' \ '(- *)'{-v,--version}'[show version information]' \ '(-h --help -v --version -r --read-only)'-{r,-read-only}'[open db in read only mode]' \ '(-h --help -v --version -c --config)'-{c,-config}'[specify an alternative config file]:alot config file:_files' \ '(-h --help -v --version -n --notmuch-config)'-{n,-notmuch-config}'[specify an alternative notmuch config file]:notmuch config file:_files' \ '(-h --help -v --version -C --colour-mode)'-{C,-colour-mode}'[terminal colour mode]:colour mode:(1 16 256)' \ '(-h --help -v --version -p --mailindex-path)'-{p,-mailindex-path}'[path to notmuch index]:directory:_directories' \ '(-h --help -v --version -d --debug-level)'-{d,-debug-level}'[set the log level]:debug level:(debug info warning error)' \ '(-h --help -v --version -l --logfile)'-{l,-logfile}'[specify the logfile (default: /dev/null)]:log file:_files' \ ': :->command' \ '*:: :->options' \ && ret=0 case $state in command) _alot_subcommands ;; options) # Call the specific completion function for the subcommand. _call_function ret _alot_$words[1] ;; esac return ret } _alot $@ # vim: set sw=2 sts=2 ts=2 et ft=zsh : alot-0.11/extra/hooks/000077500000000000000000000000001466311112200146355ustar00rootroot00000000000000alot-0.11/extra/hooks/external_command_tmux_without_x11.py000066400000000000000000000006071466311112200240630ustar00rootroot00000000000000import os def touch_external_cmdlist(cmd, shell=False, spawn=False, thread=False): # Used to handle the case where tmux isn't running within X11 if spawn and 'TMUX' in os.environ: termcmdlist = ['tmux-horizontal-split-blocking.sh'] cmd = termcmdlist + cmd # Required to avoid tmux trying to nest itself. shell = False return cmd, shell, thread alot-0.11/extra/theme_test.py000066400000000000000000000065711466311112200162360ustar00rootroot00000000000000""" Theme tester """ import sys import urwid from alot.settings import theme WIDTH = 44 def as_attr(t, colourmode, name): """ Get urwid Attr from theme file """ s = name.split(".") if len(s) == 2: attr = t.get_attribute(colourmode, s[0], s[1]) elif len(s) == 3: attr = t._config[s[0]][s[1]][s[2]][t._colours.index(colourmode)] elif len(s) == 4: attr = t._config[s[0]][s[1]][s[2]][s[3]][t._colours.index(colourmode)] return [f"{name}: ".rjust(WIDTH), (attr, "A B C")] def get_text(t, colourmode): txt = [f"\nColourmode: {colourmode}\n"] for i, name in enumerate( ( "global.footer", "global.body", "global.notify_error", "global.notify_normal", "global.prompt", "global.tag", "global.tag_focus", "help.text", "help.section", "help.title", "bufferlist.line_focus", "bufferlist.line_even", "bufferlist.line_odd", "taglist.line_focus", "taglist.line_even", "taglist.line_odd", "namedqueries.line_focus", "namedqueries.line_even", "namedqueries.line_odd", "thread.arrow_heads", "thread.arrow_bars", "thread.attachment", "thread.attachment_focus", "thread.body", "thread.body_focus", "thread.header", "thread.header_key", "thread.header_value", "thread.summary.even", "thread.summary.odd", "thread.summary.focus", "envelope.body", "envelope.header", "envelope.header_key", "envelope.header_value", "search.threadline.normal", "search.threadline.focus", "search.threadline.parts", "search.threadline.date.normal", "search.threadline.date.focus", "search.threadline.mailcount.normal", "search.threadline.mailcount.focus", "search.threadline.tags.normal", "search.threadline.tags.focus", "search.threadline.authors.normal", "search.threadline.authors.focus", "search.threadline.subject.normal", "search.threadline.subject.focus", "search.threadline.content.normal", "search.threadline.content.focus", "search.threadline-unread.normal", "search.threadline-unread.date.normal", "search.threadline-unread.mailcount.normal", "search.threadline-unread.tags.normal", "search.threadline-unread.authors.normal", "search.threadline-unread.subject.normal", "search.threadline-unread.content.normal", ) ): txt += as_attr(t, colourmode, name) if i % 4 == 0: txt.append("\n") return txt def main(): """ Theme tester """ if len(sys.argv) > 1: theme_filename = sys.argv[1] else: theme_filename = "alot/defaults/default.theme" with open(theme_filename, encoding="utf8") as f: t = theme.Theme(f) txt = [] for colourmode in (1, 16, 256): txt += get_text(t, colourmode) fill = urwid.Filler(urwid.Text(txt), "top") loop = urwid.MainLoop(fill) loop.run() if __name__ == "__main__": main() alot-0.11/extra/themes/000077500000000000000000000000001466311112200147775ustar00rootroot00000000000000alot-0.11/extra/themes/mutt000066400000000000000000000077211466311112200157220ustar00rootroot00000000000000############################################################################### # MUTT # # colour theme for alot. © 2012 Patrick Totzke, GNU GPL3+ # https://github.com/pazz/alot ############################################################################### [global] footer = 'standout,bold','','light green,bold','dark blue','light green,bold','dark blue' body = '','','light gray','black','light gray','black' notify_error = 'standout','','light gray','dark red','light gray','dark red' notify_normal = '','','light gray','black','light gray','#68a' prompt = '','','light gray','black','light gray','black' tag = '','','yellow','','yellow','' tag_focus = 'standout, bold','','yellow','','yellow','' [help] text = '','','light gray','dark gray','light gray','dark gray' section = 'underline','','white,underline','dark gray','white,underline','dark gray' title = 'standout','','white,underline','dark gray','white,underline','dark gray' [bufferlist] line_even = '','','light gray','black','light gray','black' line_odd = '','','light gray','black','light gray','black' line_focus = 'standout','','black','dark cyan','black','dark cyan' [namedqueries] line_even = '','','light gray','black','light gray','black' line_odd = '','','light gray','black','light gray','black' line_focus = 'standout','','black','dark cyan','black','dark cyan' [taglist] line_even = '','','light gray','black','light gray','black' line_odd = '','','light gray','black','light gray','black' line_focus = 'standout','','black','dark cyan','black','dark cyan' [thread] arrow_heads = '','','dark red','black','dark red','black' arrow_bars = '','','dark red','black','dark red','black' attachment = '','','yellow,bold','black','yellow,bold','black' attachment_focus = 'standout','','black','yellow','black','yellow' body = '','','light gray','black','light gray','black' body_focus = '','','light gray','black','light gray','dark gray' header = '','','dark cyan','black','dark cyan','black' header_key = '','','dark cyan','black','dark cyan','black' header_value = '','','dark cyan','black','dark cyan','black' [[summary]] even = '','','light gray','black','light gray','black' odd = '','','light gray','black','light gray','black' focus = 'standout','','black','dark cyan','black','dark cyan' [envelope] body = '','','light gray','black','light gray','black' header = '','','dark cyan','black','dark cyan','black' header_key = '','','dark cyan','black','dark cyan','black' header_value = '','','dark cyan','black','dark cyan','black' [search] [[threadline]] normal = '','','light gray','black','light gray','black' focus = 'standout','','black','dark cyan','black','dark cyan' parts = date,authors,mailcount,subject,tags [[[date]]] normal = '','','light gray','black','light gray','black' focus = 'standout','','black','dark cyan','black','dark cyan' width = 'fit',10,10 alignment = right [[[mailcount]]] normal = '','','light gray','black','light gray','black' focus = 'standout','','black','dark cyan','black','dark cyan' width = 'fit', 5,5 [[[tags]]] normal = '','','yellow','black','yellow','black' focus = 'standout','','black','dark cyan','black','dark cyan' [[[authors]]] normal = '','','light gray','black','light gray','black' focus = 'standout','','black','dark cyan','black','dark cyan' width = 'fit',25,25 [[[subject]]] normal = '','','light gray','black','light gray','black' focus = 'standout','','black','dark cyan','black','dark cyan' width = 'weight', 1 [[[content]]] normal = '','','light gray','black','light gray','black' focus = 'standout','','black','dark cyan','black','dark cyan' width = 'weight', 1 alot-0.11/extra/themes/solarized_dark000066400000000000000000000146611466311112200177270ustar00rootroot00000000000000############################################################################### # SOLARIZED DARK # # colour theme for alot. © 2012 Patrick Totzke, GNU GPL3+ # https://ethanschoonover.com/solarized # https://github.com/pazz/alot ############################################################################### # # Define mappings from solarized colour names to urwid attribute names for 16 # and 256 colour modes. These work well assuming you use the solarized term # colours via Xressources/Xdefaults # For urxvt, set 'URxvt.intensityStyles: false' in your ~/.Xdresources base03 = 'dark gray' base02 = 'black' base01 = 'light green' base00 = 'yellow' base0 = 'default' base1 = 'dark gray' base2 = 'light gray' base3 = 'white' yellow = 'brown' orange = 'light red' red = 'dark red' magenta = 'dark magenta' violet = 'light magenta' blue = 'dark blue' cyan = 'dark cyan' green = 'dark green' [global] footer = 'standout','default','%(base0)s','%(base02)s','%(base0)s','%(base02)s' body = 'default','default','%(base0)s','%(base03)s','%(base0)s','%(base03)s' notify_error = 'standout','default','%(base3)s','%(red)s','%(base3)s','%(red)s' notify_normal = 'default','default','%(blue)s','%(base02)s','%(blue)s','%(base02)s' prompt = 'default','default','%(base0)s','%(base02)s','%(base0)s','%(base02)s' tag = 'default','default','%(yellow)s','%(base03)s','%(yellow)s','%(base03)s' tag_focus = 'standout','default','%(base03)s','%(yellow)s','%(base03)s','%(yellow)s' [help] text = 'default','default','%(base0)s','%(base02)s','%(base0)s','%(base02)s' section = 'underline','default','%(cyan)s,bold','%(base02)s','%(cyan)s,bold','%(base02)s' title = 'standout','default','%(yellow)s','%(base02)s','%(yellow)s','%(base02)s' frame = 'standout','default','%(base1)s','%(base02)s','%(base1)s,bold','%(base02)s' [namedqueries] line_even = 'default','default','%(base0)s','%(base02)s','%(base0)s','%(base02)s' line_focus = 'standout','default','%(base1)s','%(base01)s','%(base1)s','%(base01)s' line_odd = 'default','default','%(base0)s','%(base03)s','%(base0)s','%(base03)s' [taglist] line_even = 'default','default','%(base0)s','%(base02)s','%(base0)s','%(base02)s' line_focus = 'standout','default','%(base1)s','%(base01)s','%(base1)s','%(base01)s' line_odd = 'default','default','%(base0)s','%(base03)s','%(base0)s','%(base03)s' [bufferlist] line_even = 'default','default','%(base0)s','%(base02)s','%(base0)s','%(base02)s' line_focus = 'standout','default','%(base1)s','%(base01)s','%(base1)s','%(base01)s' line_odd = 'default','default','%(base0)s','%(base03)s','%(base0)s','%(base03)s' [thread] attachment = 'default','default','%(base0)s','%(base03)s','%(base0)s','%(base03)s' attachment_focus = 'underline','default','%(base02)s','%(yellow)s','%(base02)s','%(yellow)s' arrow_bars = 'default','default','%(yellow)s','%(base03)s','%(yellow)s','%(base03)s' arrow_heads = 'default','default','%(yellow)s','%(base03)s','%(yellow)s','%(base03)s' body = 'default','default','%(base0)s','%(base03)s','%(base0)s','%(base03)s' body_focus = 'default','default','%(base0)s','%(base02)s','%(base0)s','%(base02)s' header = 'default','default','%(base0)s','%(base03)s','%(base0)s','%(base03)s' header_key = 'default','default','%(red)s','%(base03)s','%(red)s','%(base03)s' header_value = 'default','default','%(blue)s','%(base03)s','%(blue)s','%(base03)s' [[summary]] even = 'default','default','%(base0)s','%(base02)s','%(base0)s','%(base02)s' focus = 'standout','default','%(base1)s','%(base01)s','%(base1)s','%(base01)s' odd = 'default','default','%(base0)s','%(base03)s','%(base0)s','%(base03)s' [envelope] body = 'default','default','%(base0)s','%(base03)s','%(base0)s','%(base03)s' header = 'default','default','%(base0)s','%(base03)s','%(base0)s','%(base03)s' header_key = 'default','default','%(red)s','%(base03)s','%(red)s','%(base03)s' header_value = 'default','default','%(blue)s','%(base03)s','%(blue)s','%(base03)s' [search] [[threadline]] normal = 'default','default','%(base1)s','%(base03)s','%(base1)s','%(base03)s' focus = 'standout','default','%(base02)s','%(base01)s','%(base02)s','%(base01)s' parts = date,mailcount,tags,authors,subject [[[date]]] normal = 'default','default','%(yellow)s','%(base03)s','%(yellow)s','%(base03)s' focus = 'standout','default','%(base02)s,bold','%(base01)s','%(base02)s,bold','%(base01)s' alignment = right width = fit, 9, 9 [[[mailcount]]] normal = 'default','default','%(blue)s','%(base03)s','%(blue)s','%(base03)s' focus = 'standout','default','%(base02)s','%(base01)s','%(base02)s','%(base01)s' [[[tags]]] normal = 'default','default','%(cyan)s','%(base03)s','%(cyan)s','%(base03)s' focus = 'standout','default','%(base02)s','%(base01)s','%(base02)s','%(base01)s' [[[authors]]] normal = 'default,underline','default','%(blue)s','%(base03)s','%(blue)s','%(base03)s' focus = 'standout','default','%(base02)s','%(base01)s','%(base02)s','%(base01)s' width = 'fit',0,30 [[[subject]]] normal = 'default','default','%(base0)s','%(base03)s','%(base0)s','%(base03)s' focus = 'standout','default','%(base02)s,bold','%(base01)s','%(base02)s,bold','%(base01)s' width = 'weight',1 [[[content]]] normal = 'default','default','%(base01)s','%(base03)s','%(base01)s','%(base03)s' focus = 'standout','default','%(base02)s','%(base01)s','%(base02)s','%(base01)s' [[threadline-unread]] normal = 'default','default','%(base1)s,bold','%(base03)s','%(base1)s,bold','%(base03)s' tagged_with = 'unread' [[[date]]] normal = 'default','default','%(yellow)s,bold','%(base03)s','%(yellow)s,bold','%(base03)s' [[[mailcount]]] normal = 'default','default','%(blue)s,bold','%(base03)s','%(blue)s,bold','%(base03)s' [[[tags]]] normal = 'bold','default','light cyan','%(base03)s','light cyan','%(base03)s' [[[authors]]] normal = 'default,underline','default','%(blue)s','%(base03)s','%(blue)s,bold','%(base03)s' [[[subject]]] normal = 'default','default','%(base2)s','%(base03)s','%(base2)s','%(base03)s' [[[content]]] normal = 'default','default','%(base01)s,bold','%(base03)s','%(base01)s,bold','%(base03)s' alot-0.11/extra/themes/solarized_light000066400000000000000000000171711466311112200201140ustar00rootroot00000000000000############################################################################### # SOLARIZED LIGHT # # colour theme for alot. © 2012 Patrick Totzke, GNU GPL3+ # https://ethanschoonover.com/solarized # https://github.com/pazz/alot ############################################################################### # # Define mappings from solarized colour names to urwid attribute names for 16 # and 256 colour modes. These work well assuming you use the solarized term # colours via Xressources/Xdefaults. You might want to change this otherwise 16_base03 = 'dark gray' 16_base02 = 'black' 16_base01 = 'light green' 16_base00 = 'yellow' 16_base0 = 'light blue' 16_base1 = 'light cyan' 16_base2 = 'light gray' 16_base3 = 'white' 16_yellow = 'brown' 16_orange = 'light red' 16_red = 'dark red' 16_magenta = 'dark magenta' 16_violet = 'light magenta' 16_blue = 'dark blue' 16_cyan = 'dark cyan' 16_green = 'dark green' # Use a slightly different mapping here to be able to use "bold" in 256c mode 256_base03 = 'dark gray' 256_base02 = 'black' 256_base01 = 'light green' 256_base00 = 'yellow' 256_base0 = 'g50' #808080 256_base1 = 'g52' #848484 - approximates #8a8a8a 256_base2 = 'light gray' 256_base3 = 'white' 256_yellow = 'brown' 256_orange = 'light red' 256_red = 'dark red' 256_magenta = 'dark magenta' 256_violet = 'light magenta' 256_blue = 'dark blue' 256_cyan = '#0aa' #00afaf 256_green = 'dark green' # This is the actual alot theme [global] footer = 'standout','default','%(16_base01)s','%(16_base2)s','%(256_base01)s','%(256_base2)s' body = 'default','default','%(16_base00)s','%(16_base3)s','%(256_base00)s','%(256_base3)s' notify_error = 'standout','default','%(16_base3)s','%(16_red)s','%(256_base3)s','%(256_red)s' notify_normal = 'default','default','%(16_base00)s','%(16_base2)s','%(256_base00)s','%(256_base2)s' prompt = 'default','default','%(16_base00)s','%(16_base2)s','%(256_base00)s','%(256_base2)s' tag = 'default','default','%(16_yellow)s','%(16_base3)s','%(256_yellow)s','%(256_base3)s' tag_focus = 'standout','default','%(16_base3)s','%(16_yellow)s','%(256_base3)s','%(256_yellow)s' [help] text = 'default','default','%(16_base00)s','%(16_base2)s','%(256_base00)s','%(256_base2)s' section = 'underline','default','%(16_base01)s,underline','%(16_base2)s','%(256_base01)s,underline','%(256_base2)s' title = 'standout','default','%(16_base01)s','%(16_base2)s','%(256_base01)s','%(256_base2)s' [namedqueries] line_focus = 'standout','default','%(16_base2)s','%(16_yellow)s','%(256_base2)s','%(256_yellow)s' line_even = 'default','default','%(16_base00)s','%(16_base3)s','%(256_base00)s','%(256_base3)s' line_odd = 'default','default','%(16_base00)s','%(16_base2)s','%(256_base00)s','%(256_base2)s' [taglist] line_focus = 'standout','default','%(16_base2)s','%(16_yellow)s','%(256_base2)s','%(256_yellow)s' line_even = 'default','default','%(16_base00)s','%(16_base3)s','%(256_base00)s','%(256_base3)s' line_odd = 'default','default','%(16_base00)s','%(16_base2)s','%(256_base00)s','%(256_base2)s' [bufferlist] line_focus = 'standout','default','%(16_base2)s','%(16_yellow)s','%(256_base2)s','%(256_yellow)s' line_even = 'default','default','%(16_base00)s','%(16_base3)s','%(256_base00)s','%(256_base3)s' line_odd = 'default','default','%(16_base00)s','%(16_base2)s','%(256_base00)s','%(256_base2)s' [thread] attachment = 'default','default','%(16_base00)s','%(16_base3)s','%(256_base00)s','%(256_base3)s' attachment_focus = 'underline','default','%(16_base2)s','%(16_yellow)s','%(256_base2)s','%(256_yellow)s' body = 'default','default','%(16_base00)s','%(16_base3)s','%(256_base00)s','%(256_base3)s' body_focus = 'default','default','%(16_base00)s','%(16_base3)s','%(256_base00)s','%(256_base2)s' arrow_bars = 'default','default','%(16_yellow)s','%(16_base3)s','%(256_yellow)s','%(256_base3)s' arrow_heads = 'default','default','%(16_yellow)s','%(16_base3)s','%(256_yellow)s','%(256_base3)s' header = 'default','default','%(16_base00)s','%(16_base2)s','%(256_base00)s','%(256_base2)s' header_key = 'default','default','%(16_magenta)s','%(16_base2)s','%(256_magenta)s','%(256_base2)s' header_value = 'default','default','%(16_blue)s','%(16_base2)s','%(256_blue)s','%(256_base2)s' [[summary]] even = 'default','default','%(16_base00)s','%(16_base2)s','%(256_base00)s','%(256_base2)s' focus = 'standout','default','%(16_base3)s','%(16_yellow)s','%(256_base3)s','%(256_yellow)s' odd = 'default','default','%(16_base00)s','%(16_base3)s','%(256_base00)s','%(256_base3)s' [envelope] body = 'default','default','%(16_base00)s','%(16_base3)s','%(256_base00)s','%(256_base3)s' header = 'default','default','%(16_base00)s','%(16_base2)s','%(256_base00)s','%(256_base2)s' header_key = 'default','default','%(16_orange)s','%(16_base2)s','%(256_orange)s','%(256_base2)s' header_value = 'default','default','%(16_violet)s','%(16_base2)s','%(256_violet)s','%(256_base2)s' [search] [[threadline]] normal = 'default','default','%(16_base01)s','%(16_base3)s','%(256_base01)s','%(256_base3)s' focus = 'standout','default','%(16_base2)s','%(16_yellow)s','%(256_base2)s','%(256_yellow)s' parts = date,mailcount,tags,authors,subject [[[date]]] normal = 'default','default','%(16_base01)s','%(16_base3)s','%(256_base01)s','%(256_base3)s' focus = 'standout','default','%(16_base3)s','%(16_yellow)s','%(256_base3)s','%(256_yellow)s' alignment = right width = fit, 9, 9 [[[mailcount]]] normal = 'default','default','%(16_base01)s','%(16_base3)s','%(256_base01)s','%(256_base3)s' focus = 'standout','default','%(16_base2)s','%(16_yellow)s','%(256_base2)s','%(256_yellow)s' [[[tags]]] normal = 'bold','default','%(16_yellow)s','%(16_base3)s','%(256_yellow)s','%(256_base3)s' focus = 'standout','default','%(16_base3)s','%(16_yellow)s','%(256_base3)s','%(256_yellow)s' [[[authors]]] normal = 'default,underline','default','%(16_blue)s','%(16_base3)s','%(256_blue)s','%(256_base3)s' focus = 'standout','default','%(16_base2)s','%(16_yellow)s','%(256_base2)s','%(256_yellow)s' width = 'fit',0,30 [[[subject]]] normal = 'default','default','%(16_base00)s','%(16_base3)s','%(256_base00)s','%(256_base3)s' focus = 'standout','default','%(16_base3)s','%(16_yellow)s','%(256_base3)s','%(256_yellow)s' width = 'weight',1 [[[content]]] normal = 'default','default','%(16_base1)s','%(16_base3)s','%(256_base1)s','%(256_base3)s' focus = 'standout','default','%(16_base2)s','%(16_yellow)s','%(256_base2)s','%(256_yellow)s' [[threadline-unread]] normal = 'default','default','%(16_base01)s,bold','%(16_base2)s','%(256_base01)s,bold','%(256_base2)s' tagged_with = 'unread' [[[date]]] normal = 'default','default','%(16_base01)s,bold','%(16_base2)s','%(256_base01)s,bold','%(256_base2)s' [[[mailcount]]] normal = 'default','default','%(16_base01)s,bold','%(16_base2)s','%(256_base01)s,bold','%(256_base2)s' [[[tags]]] normal = 'bold','default','%(16_yellow)s','%(16_base2)s','%(256_yellow)s','%(256_base2)s' [[[authors]]] normal = 'default,underline','default','%(16_violet)s','%(16_base2)s','%(256_violet)s','%(256_base2)s' [[[subject]]] normal = 'default','default','%(16_base02)s,bold','%(16_base2)s','%(256_base02)s,bold','%(256_base2)s' [[[content]]] normal = 'default','default','%(16_base1)s,bold','%(16_base2)s','%(256_base1)s,bold','%(256_base2)s' alot-0.11/extra/themes/sup000066400000000000000000000103651466311112200155360ustar00rootroot00000000000000############################################################################### # SUP # # colour theme for alot. © 2012 Patrick Totzke, GNU GPL3+ # https://github.com/pazz/alot ############################################################################### [global] footer = 'standout,bold','','white,bold','dark blue','white,bold','dark blue' body = '','','light gray','black','light gray','g0' notify_error = 'standout','','light gray','dark red','light gray','dark red' notify_normal = '','','light gray','black','light gray','#68a' prompt = '','','light gray','black','light gray','g0' tag = '','','yellow','','yellow','' tag_focus = 'standout, bold','','yellow','','yellow','' [help] text = '','','light gray','dark gray','light gray','dark gray' section = 'underline','','white,underline','dark gray','white,underline','dark gray' title = 'standout','','white,underline','dark gray','white,underline','dark gray' [bufferlist] line_even = '','','light gray','black','light gray','g0' line_odd = '','','light gray','black','light gray','g0' line_focus = 'standout','','black','dark cyan','black','dark cyan' [namedqueries] line_even = '','','light gray','black','light gray','g0' line_odd = '','','light gray','black','light gray','g0' line_focus = 'standout','','black','dark cyan','black','dark cyan' [taglist] line_even = '','','light gray','black','light gray','g0' line_odd = '','','light gray','black','light gray','g0' line_focus = 'standout','','black','dark cyan','black','dark cyan' [thread] arrow_heads = '','','black','black','g0','g0' arrow_bars = '','','black','black','g0','g0' attachment = '','','dark cyan','black','dark cyan','g0' attachment_focus = 'standout','','black','dark cyan','black','dark cyan' body = '','','light gray','black','light gray','g0' body_focus = '','','light gray','black','light gray','dark gray' header = '','','dark cyan','black','dark cyan','g0' header_key = '','','brown','black','brown','g0' header_value = '','','brown','black','brown','g0' [[summary]] even = '','','light gray','dark green','black','dark green' odd = '','','light gray','dark green','black','dark green' focus = 'standout','','black','dark cyan','black','dark cyan' [envelope] body = '','','light gray','black','light gray','g0' header = '','','dark cyan','black','dark cyan','g0' header_key = '','','dark cyan','black','dark cyan','g0' header_value = '','','dark cyan','black','dark cyan','g0' [search] [[threadline]] normal = '','','light gray','black','light gray','g0' focus = 'standout','','black','dark cyan','black','dark cyan' parts = date,authors,mailcount,subject,tags,content [[[date]]] normal = '','','light gray','black','light gray','g0' focus = 'standout','','black,bold','dark cyan','black,bold','dark cyan' width = 'fit',10,10 alignment = right [[[mailcount]]] normal = '','','light gray','black','light gray','g0' focus = 'standout','','black,bold','dark cyan','black,bold','dark cyan' alignment = right width = 'fit', 5,5 template = '{%d}' [[[tags]]] normal = '','','brown','black','brown','g0' focus = 'standout','','yellow,bold','dark cyan','yellow,bold','dark cyan' [[[authors]]] normal = '','','light gray','black','light gray','g0' focus = 'standout','','black,bold','dark cyan','black,bold','dark cyan' width = 'fit',18,18 alignment = left [[[subject]]] normal = '','','light gray','black','light gray','g0' focus = 'standout','','black,bold','dark cyan','black,bold','dark cyan' alignment = left width = 'fit', 0, 0 [[[content]]] normal = '','','dark cyan','black','dark cyan','g0' focus = 'standout','','black','dark cyan','black','dark cyan' width = 'weight', 1 [[threadline-unread]] tagged_with = unread [[[authors]]] normal = '','','white,bold','black','white,bold','g0' [[[subject]]] normal = '','','white,bold','black','white,bold','g0' alot-0.11/extra/themes/tomorrow000066400000000000000000000176671466311112200166330ustar00rootroot00000000000000# Tomorrow – color theme for alot # # Copyright (c) 2014 Martin Zimmermann , GNU GPL3+ # # https://github.com/chriskempson/tomorrow-theme # https://github.com/pazz/alot error = 'dark red' 16_text_fg = 'black' 16_text_bg = 'default' 256_text_fg = 'black' 256_text_bg = 'default' 16_alternate = 'dark magenta' 256_alternate = 'dark magenta' 16_gray_bg = 'light gray' 256_gray_bg = 'light gray' 16_normal_fg = 'black' 16_normal_bg = 'default' 256_normal_fg = 'g11' 256_normal_bg = 'default' 16_focus_fg = 'black' 16_focus_bg = 'default' 256_focus_fg = 'g11' 256_focus_bg = 'g93' 16_unread_fg = 'dark red' 16_unread_bg = 'default' 256_unread_fg = '#d00' 256_unread_bg = 'g93' 16_tags_fg = 'brown' 16_tags_bg = 'default' 256_tags_fg = 'brown' 256_tags_bg = 'default' 16_author_fg = 'dark blue' 16_author_bg = 'default' 256_author_fg = '#068' 256_author_bg = 'default' 16_date_fg = 'black' 16_date_bg = 'default' 256_date_fg = 'g11' 256_date_bg = 'default' 16_mail_fg = 'dark green' 16_mail_bg = 'default' 256_mail_fg = '#680' 256_mail_bg = 'default' 16_header_fg = 'dark blue' 16_header_bg = 'light gray' 256_header_fg = 'dark blue' 256_header_bg = 'g93' # best viewed in fullscreen [global] footer = 'standout','default','%(16_alternate)s','%(16_gray_bg)s','%(256_alternate)s','%(256_gray_bg)s' body = 'default','default','%(16_text_fg)s','%(16_text_bg)s','%(256_text_fg)s','%(256_text_bg)s' notify_error = 'standout','default','%(16_text_fg)s','%(error)s','%(256_text_fg)s','%(error)s' notify_normal = 'default','default','%(16_text_fg)s','%(16_gray_bg)s','%(256_text_fg)s','%(256_gray_bg)s' prompt = 'default','default','%(16_text_fg)s','%(16_gray_bg)s','%(256_text_fg)s','%(256_gray_bg)s' tag = 'default','default','%(16_tags_fg)s','%(16_tags_bg)s','%(256_tags_fg)s','%(256_tags_bg)s' tag_focus = 'standout','default','%(16_focus_fg)s','%(16_focus_bg)s','%(256_focus_fg)s','%(256_focus_bg)s' [help] text = 'default','default','%(16_text_fg)s','%(16_gray_bg)s','%(256_text_fg)s','%(256_gray_bg)s' section = 'underline','default','%(16_alternate)s,underline','%(16_gray_bg)s','%(256_alternate)s,underline','%(256_gray_bg)s' title = 'standout','default','%(16_alternate)s','%(16_gray_bg)s','%(256_alternate)s','%(256_gray_bg)s' [namedqueries] line_focus = 'standout','default','%(16_focus_fg)s','%(16_focus_bg)s','%(256_focus_fg)s','%(256_focus_bg)s' line_even = 'default','default','%(16_text_fg)s','%(16_normal_bg)s','%(256_text_fg)s','%(256_normal_bg)s' line_odd = 'default','default','%(16_text_fg)s','%(16_normal_bg)s','%(256_text_fg)s','%(256_normal_bg)s' [taglist] line_focus = 'standout','default','%(16_focus_fg)s','%(16_focus_bg)s','%(256_focus_fg)s','%(256_focus_bg)s' line_even = 'default','default','%(16_text_fg)s','%(16_normal_bg)s','%(256_text_fg)s','%(256_normal_bg)s' line_odd = 'default','default','%(16_text_fg)s','%(16_normal_bg)s','%(256_text_fg)s','%(256_normal_bg)s' [bufferlist] line_focus = 'standout','default','%(16_focus_fg)s','%(16_focus_bg)s','%(256_focus_fg)s','%(256_focus_bg)s' line_even = 'default','default','%(16_text_fg)s','%(16_normal_bg)s','%(256_text_fg)s','%(256_normal_bg)s' line_odd = 'default','default','%(16_text_fg)s','%(16_normal_bg)s','%(256_text_fg)s','%(256_normal_bg)s' [thread] attachment = 'default','default','%(16_text_fg)s','%(16_text_bg)s','%(256_text_fg)s','%(256_text_bg)s' attachment_focus = 'underline','default','%(16_gray_bg)s','%(16_alternate)s','%(256_gray_bg)s','%(256_alternate)s' body = 'default','default','%(16_text_fg)s','%(16_text_bg)s','%(256_text_fg)s','%(256_text_bg)s' body_focus = 'default','default','%(16_text_fg)s','%(16_gray_bg)s','%(256_text_fg)s','%(256_gray_bg)s' arrow_bars = 'default','default','%(16_tags_fg)s','%(16_tags_bg)s','%(256_tags_fg)s','%(256_tags_bg)s' arrow_heads = 'default','default','%(16_tags_fg)s','%(16_tags_bg)s','%(256_tags_fg)s','%(256_tags_bg)s' header = 'default','default','%(16_text_fg)s','%(16_header_bg)s','%(256_text_fg)s','%(256_header_bg)s' header_key = 'default','default','%(16_alternate)s','%(16_header_bg)s','%(256_alternate)s','%(256_header_bg)s' header_value = 'default','default','%(16_header_fg)s','%(16_header_bg)s','%(256_header_fg)s','%(256_header_bg)s' [[summary]] odd = 'default','default','%(16_text_fg)s','%(16_text_bg)s','%(256_text_fg)s','%(256_text_bg)s' even = 'default','default','%(16_text_fg)s','%(16_text_bg)s','%(256_text_fg)s','%(256_text_bg)s' focus = 'standout','default','%(16_focus_fg)s','%(16_focus_bg)s','%(256_focus_fg)s','%(256_focus_bg)s' [envelope] body = 'default','default','%(16_text_fg)s','%(16_text_bg)s','%(256_text_fg)s','%(256_text_bg)s' header = 'default','default','%(16_text_fg)s','%(16_header_bg)s','%(256_text_fg)s','%(256_header_bg)s' header_key = 'default','default','%(16_alternate)s','%(16_header_bg)s','%(256_alternate)s','%(256_header_bg)s' header_value = 'default','default','%(16_header_fg)s','%(16_header_bg)s','%(256_header_fg)s','%(256_header_bg)s' [search] [[threadline]] normal = 'default','default','%(16_alternate)s','%(16_text_bg)s','%(256_alternate)s','%(256_text_bg)s' focus = 'standout','default','%(16_focus_fg)s,bold','%(16_focus_bg)s','%(256_focus_fg)s','%(256_focus_bg)s' parts = date,mailcount,tags,authors,subject [[[date]]] normal = 'default','default','%(16_date_fg)s','%(16_date_bg)s','%(256_date_fg)s','%(256_date_bg)s' focus = 'standout','default','%(16_date_fg)s,bold','%(16_focus_bg)s','%(256_date_fg)s','%(256_focus_bg)s' [[[mailcount]]] normal = 'default','default','%(16_mail_fg)s','%(16_mail_bg)s','%(256_mail_fg)s','%(256_mail_bg)s' focus = 'standout','default','%(16_mail_fg)s,bold','%(16_focus_bg)s','%(256_mail_fg)s','%(256_focus_bg)s' [[[tags]]] normal = 'bold','default','%(16_tags_fg)s','%(16_tags_bg)s','%(256_tags_fg)s','%(256_tags_bg)s' focus = 'standout','default','%(16_tags_fg)s,bold','%(16_focus_bg)s','%(256_tags_fg)s','%(256_focus_bg)s' [[[authors]]] normal = 'default,underline','default','%(16_author_fg)s','%(16_author_bg)s','%(256_author_fg)s','%(256_author_bg)s' focus = 'standout','default','%(16_author_fg)s,bold','%(16_focus_bg)s','%(256_author_fg)s','%(256_focus_bg)s' width = 'fit',0,30 [[[subject]]] normal = 'default','default','%(16_text_fg)s','%(16_text_bg)s','%(256_text_fg)s','%(256_text_bg)s' focus = 'standout','default','%(16_focus_fg)s,bold','%(16_focus_bg)s','%(256_focus_fg)s','%(256_focus_bg)s' width = 'weight',1 [[[content]]] normal = 'default','default','%(16_text_fg)s','%(16_text_bg)s','%(256_text_fg)s','%(256_text_bg)s' focus = 'standout','default','%(16_gray_bg)s','%(16_tags_fg)s','%(256_gray_bg)s','%(256_tags_fg)s' [[threadline-unread]] normal = 'default','default','%(16_normal_fg)s','%(16_normal_bg)s','%(256_normal_fg)s','%(256_normal_bg)s' tagged_with = 'unread' [[[date]]] normal = 'default','default','%(16_date_fg)s','%(16_date_bg)s','%(256_date_fg)s','%(256_date_bg)s' [[[mailcount]]] normal = 'default','default','%(16_unread_fg)s','%(16_mail_bg)s','%(256_unread_fg)s','%(256_mail_bg)s' focus = 'default','default','%(16_unread_fg)s,bold','%(16_focus_bg)s','%(256_unread_fg)s','%(256_focus_bg)s' [[[tags]]] normal = 'bold','default','%(16_tags_fg)s','%(16_tags_bg)s','%(256_tags_fg)s','%(256_tags_bg)s' [[[authors]]] normal = 'default,underline','default','%(16_author_fg)s','%(16_normal_bg)s','%(256_author_fg)s','%(256_normal_bg)s' [[[subject]]] normal = 'default','default','%(16_normal_fg)s','%(16_normal_bg)s','%(256_normal_fg)s','%(256_normal_bg)s' [[[content]]] normal = 'default','default','%(16_normal_fg)s','%(16_normal_bg)s','%(256_normal_fg)s','%(256_normal_bg)s' alot-0.11/extra/tmux-horizontal-split-blocking.sh000077500000000000000000000010011466311112200221440ustar00rootroot00000000000000#!/bin/sh # Uses unique tmux wait-for channel based on time with nanoseconds # Inspired by cjpbirkbeck's example @ https://github.com/pazz/alot/issues/1560#issuecomment-907222165 IFS=' ' # Ensure we're actually running inside tmux: if [ -n "$TMUX" ]; then fin=$(date +%s%N) # Use new-window to create a new window instead. tmux split-window -h "${*}; tmux wait-for -S ${fin}" tmux wait-for "${fin}" else # You can replace xterm with your preferred terminal emulator. xterm -e "${*}" fi alot-0.11/flake.lock000066400000000000000000000027311466311112200143260ustar00rootroot00000000000000{ "nodes": { "flake-utils": { "inputs": { "systems": "systems" }, "locked": { "lastModified": 1710146030, "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", "owner": "numtide", "repo": "flake-utils", "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", "type": "github" }, "original": { "owner": "numtide", "repo": "flake-utils", "type": "github" } }, "nixpkgs": { "locked": { "lastModified": 1714253743, "narHash": "sha256-mdTQw2XlariysyScCv2tTE45QSU9v/ezLcHJ22f0Nxc=", "owner": "NixOS", "repo": "nixpkgs", "rev": "58a1abdbae3217ca6b702f03d3b35125d88a2994", "type": "github" }, "original": { "owner": "NixOS", "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "root": { "inputs": { "flake-utils": "flake-utils", "nixpkgs": "nixpkgs" } }, "systems": { "locked": { "lastModified": 1681028828, "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", "owner": "nix-systems", "repo": "default", "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", "type": "github" }, "original": { "owner": "nix-systems", "repo": "default", "type": "github" } } }, "root": "root", "version": 7 } alot-0.11/flake.nix000066400000000000000000000051731466311112200141770ustar00rootroot00000000000000{ description = "alot: Terminal-based Mail User Agent"; inputs.flake-utils.url = "github:numtide/flake-utils"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; # we want to extract some metadata and especially the dependencies # from the pyproject file, like this we do not have to maintain the # list a second time pyproject = pkgs.lib.trivial.importTOML ./pyproject.toml; # get a list of python packages by name, used to get the nix packages # for the dependency names from the pyproject file getPkgs = names: builtins.attrValues (pkgs.lib.attrsets.getAttrs names pkgs.python3Packages); # extract the python dependencies from the pyprojec file, cut the version constraint dependencies' = pkgs.lib.lists.concatMap (builtins.match "([^>=<]*).*") pyproject.project.dependencies; # the package is called gpg on PyPI but gpgme in nixpkgs dependencies = map (x: if x == "gpg" then "gpgme" else x) dependencies'; in { packages = { alot = pkgs.python3Packages.buildPythonApplication { name = "alot"; version = "0.dev+${if self ? shortRev then self.shortRev else "dirty"}"; src = self; pyproject = true; outputs = [ "out" "doc" "man" ]; build-system = getPkgs pyproject."build-system".requires; dependencies = getPkgs dependencies; postPatch = '' substituteInPlace alot/settings/manager.py \ --replace /usr/share "$out/share" ''; postInstall = '' installShellCompletion --zsh --name _alot extra/completion/alot-completion.zsh mkdir -p $out/share/{applications,alot} cp -r extra/themes $out/share/alot sed "s,/usr/bin,$out/bin,g" extra/alot.desktop > $out/share/applications/alot.desktop ''; checkPhase = '' python3 -m unittest -v ''; nativeCheckInputs = with pkgs; [ gnupg notmuch procps ]; nativeBuildInputs = with pkgs; [ python3Packages.sphinxHook installShellFiles ]; sphinxBuilders = [ "html" "man" ]; }; docs = pkgs.lib.trivial.warn "The docs attribute moved to alot.doc" self.packages.${system}.alot.doc; default = self.packages.${system}.alot; }; }); } alot-0.11/pyproject.toml000066400000000000000000000024651466311112200153120ustar00rootroot00000000000000[build-system] requires = ["setuptools", "setuptools-scm"] build-backend = "setuptools.build_meta" [project] name = "alot" description = "Terminal MUA using notmuch mail" authors = [ {name="Patrick Totzke", email="patricktotzke@gmail.com"} ] maintainers = [ {name="Lucas Hoffmann", email="lucc@posteo.de"}, ] readme = "README.md" dynamic = ["version"] requires-python = ">=3.8" license = { text = "GPL-3.0-or-later" } classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console :: Curses", "Intended Audience :: End Users/Desktop", "Programming Language :: Python :: 3 :: Only", "Topic :: Communications :: Email :: Email Clients (MUA)", "Topic :: Database :: Front-Ends", 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', ] dependencies = [ "notmuch2>=0.30", "urwid>=1.3.0", "urwidtrees>=1.0.3", "twisted>=18.4.0", "python-magic", "configobj>=4.7.0", "gpg>1.10.0", ] [project.optional-dependencies] docs = ["sphinx"] tests = ["pytest"] [project.scripts] alot = "alot.__main__:main" [project.urls] Repository = "https://github.com/pazz/alot" Documentation = "https://alot.readthedocs.io/en/latest/" Issues = "https://github.com/pazz/alot/issues" [tool.setuptools.packages.find] include = ["alot*"] [tool.setuptools_scm] alot-0.11/readthedocs.yaml000066400000000000000000000014611466311112200155420ustar00rootroot00000000000000# Read the Docs configuration file for Sphinx projects # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 # Set the OS, Python version and other tools you might need build: os: ubuntu-22.04 tools: python: "3.12" apt_packages: - libmagic1 # for python-magic jobs: # remove and mock problematic dependencies pre_install: - sed -i -e /gpg/d -e /notmuch2/d pyproject.toml - touch gpg.py - echo NullPointerError = NotmuchError = None > notmuch2.py # make the git state clean again for setuptools_scm post_install: - git checkout pyproject.toml # Install alot itself before building the docs python: install: - path: . # Build documentation in the "docs/" directory with Sphinx sphinx: configuration: docs/source/conf.py alot-0.11/setup.cfg000066400000000000000000000000741466311112200142110ustar00rootroot00000000000000[pycodestyle] count = False ignore = E501 statistics = True alot-0.11/tests/000077500000000000000000000000001466311112200135315ustar00rootroot00000000000000alot-0.11/tests/__init__.py000066400000000000000000000000001466311112200156300ustar00rootroot00000000000000alot-0.11/tests/addressbook/000077500000000000000000000000001466311112200160315ustar00rootroot00000000000000alot-0.11/tests/addressbook/__init__.py000066400000000000000000000000001466311112200201300ustar00rootroot00000000000000alot-0.11/tests/addressbook/test_abook.py000066400000000000000000000022711466311112200205370ustar00rootroot00000000000000# Copyright (C) 2017 Lucas Hoffmann # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import os import tempfile import unittest from alot.addressbook import abook from alot.settings.errors import ConfigError class TestAbookAddressBook(unittest.TestCase): def test_abook_file_can_not_be_empty(self): with self.assertRaises(ConfigError): abook.AbookAddressBook("/dev/null") def test_get_contacts_lists_all_emails(self): data = """ [format] version = unknown program = alot-test-suite [1] name = me email = me@example.com [2] name = you email = you@other.domain, you@example.com """ with tempfile.NamedTemporaryFile(mode='w+', delete=False) as tmp: tmp.write(data) path = tmp.name self.addCleanup(os.unlink, path) addressbook = abook.AbookAddressBook(path) actual = addressbook.get_contacts() expected = [('me', 'me@example.com'), ('you', 'you@other.domain'), ('you', 'you@example.com')] self.assertListEqual(actual, expected) alot-0.11/tests/addressbook/test_external.py000066400000000000000000000060671466311112200212750ustar00rootroot00000000000000# Copyright (C) 2017 Lucas Hoffmann # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import re import unittest from unittest import mock from alot.addressbook import external class TestExternalAddressbookGetContacts(unittest.TestCase): """Some test cases for alot.addressbook.external.ExternalAddressbook.get_contacts""" regex = '(?P.*)\t(?P.*)' @staticmethod def _patch_call_cmd(return_value): return mock.patch('alot.addressbook.external.call_cmd', mock.Mock(return_value=return_value)) def test_raises_if_external_command_exits_with_non_zero_status(self): abook = external.ExternalAddressbook('foobar', '') with self._patch_call_cmd(('', '', 42)): with self.assertRaises(external.AddressbookError) as contextmgr: abook.get_contacts() expected = 'abook command "foobar" returned with return code 42' self.assertEqual(contextmgr.exception.args[0], expected) def test_stderr_of_failing_command_is_part_of_exception_message(self): stderr = 'some text printed on stderr of external command' abook = external.ExternalAddressbook('foobar', '') with self._patch_call_cmd(('', stderr, 42)): with self.assertRaises(external.AddressbookError) as contextmgr: abook.get_contacts() self.assertIn(stderr, contextmgr.exception.args[0]) def test_returns_empty_list_when_command_returns_no_output(self): abook = external.ExternalAddressbook('foobar', self.regex) with self._patch_call_cmd(('', '', 0)) as call_cmd: actual = abook.get_contacts() self.assertListEqual(actual, []) call_cmd.assert_called_once_with(['foobar']) def test_splits_results_from_provider_by_regex(self): abook = external.ExternalAddressbook('foobar', self.regex) with self._patch_call_cmd( ('me\t\nyou\t', '', 0)): actual = abook.get_contacts() expected = [('me', ''), ('you', '')] self.assertListEqual(actual, expected) def test_returns_empty_list_if_regex_has_no_name_submatches(self): abook = external.ExternalAddressbook( 'foobar', self.regex.replace('name', 'xname')) with self._patch_call_cmd( ('me\t\nyou\t', '', 0)): actual = abook.get_contacts() self.assertListEqual(actual, []) def test_returns_empty_list_if_regex_has_no_email_submatches(self): abook = external.ExternalAddressbook( 'foobar', self.regex.replace('email', 'xemail')) with self._patch_call_cmd( ('me\t\nyou\t', '', 0)): actual = abook.get_contacts() self.assertListEqual(actual, []) def test_default_ignorecase(self): abook = external.ExternalAddressbook('foobar', '') self.assertIs(abook.reflags, re.IGNORECASE) alot-0.11/tests/addressbook/test_init.py000066400000000000000000000053261466311112200204130ustar00rootroot00000000000000# Copyright (C) 2017 Lucas Hoffmann # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import unittest from alot import addressbook class _AddressBook(addressbook.AddressBook): """Implements stubs for ABC methods. The return value for get_contacts can be set on instance creation.""" def __init__(self, contacts, **kwargs): self._contacts = contacts super(_AddressBook, self).__init__(**kwargs) def get_contacts(self): return self._contacts class TestAddressBook(unittest.TestCase): def test_lookup_will_match_names(self): contacts = [('foo', 'x@example.com'), ('bar', 'y@example.com'), ('baz', 'z@example.com')] abook = _AddressBook(contacts) actual = abook.lookup('bar') expected = [contacts[1]] self.assertListEqual(actual, expected) def test_lookup_will_match_emails(self): contacts = [('foo', 'x@example.com'), ('bar', 'y@example.com'), ('baz', 'z@example.com')] abook = _AddressBook(contacts) actual = abook.lookup('y@example.com') expected = [contacts[1]] self.assertListEqual(actual, expected) def test_lookup_ignores_case_by_default(self): contacts = [('name', 'email@example.com'), ('Name', 'other@example.com'), ('someone', 'someone@example.com')] abook = _AddressBook(contacts) actual = abook.lookup('name') expected = [contacts[0], contacts[1]] self.assertListEqual(actual, expected) def test_lookup_can_match_case(self): contacts = [('name', 'email@example.com'), ('Name', 'other@example.com'), ('someone', 'someone@example.com')] abook = _AddressBook(contacts, ignorecase=False) actual = abook.lookup('name') expected = [contacts[0]] self.assertListEqual(actual, expected) def test_lookup_will_match_partial_in_the_middle(self): contacts = [('name', 'email@example.com'), ('My Own Name', 'other@example.com'), ('someone', 'someone@example.com')] abook = _AddressBook(contacts) actual = abook.lookup('Own') expected = [contacts[1]] self.assertListEqual(actual, expected) def test_lookup_can_handle_special_regex_chars(self): contacts = [('name [work]', 'email@example.com'), ('My Own Name', 'other@example.com'), ('someone', 'someone@example.com')] abook = _AddressBook(contacts) actual = abook.lookup('[wor') expected = [contacts[0]] self.assertListEqual(actual, expected) alot-0.11/tests/commands/000077500000000000000000000000001466311112200153325ustar00rootroot00000000000000alot-0.11/tests/commands/__init__.py000066400000000000000000000000001466311112200174310ustar00rootroot00000000000000alot-0.11/tests/commands/test_envelope.py000066400000000000000000000336431466311112200205710ustar00rootroot00000000000000# encoding=utf-8 # Copyright © 2017-2018 Dylan Baker # 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 . """Tests for the alot.commands.envelope module.""" import email import os import tempfile import textwrap import unittest from unittest import mock from alot.commands import envelope from alot.db.envelope import Envelope from alot.errors import GPGProblem from alot.settings.errors import NoMatchingAccount from alot.settings.manager import SettingsManager from alot.account import Account from .. import utilities # When using an assert from a mock a TestCase method might not use self. That's # okay. # pylint: disable=no-self-use class TestAttachCommand(unittest.TestCase): """Tests for the AttachCommaned class.""" def test_single_path(self): """A test for an existing single path.""" ui = utilities.make_ui() with tempfile.TemporaryDirectory() as d: testfile = os.path.join(d, 'foo') with open(testfile, 'w') as f: f.write('foo') cmd = envelope.AttachCommand(path=testfile) cmd.apply(ui) ui.current_buffer.envelope.attach.assert_called_with(testfile) def test_user(self): """A test for an existing single path prefaced with ~/.""" ui = utilities.make_ui() with tempfile.TemporaryDirectory() as d: # This mock replaces expanduser to replace "~/" with a path to the # temporary directory. This is easier and more reliable than # relying on changing an environment variable (like HOME), since it # doesn't rely on CPython implementation details. with mock.patch('alot.commands.os.path.expanduser', lambda x: os.path.join(d, x[2:])): testfile = os.path.join(d, 'foo') with open(testfile, 'w') as f: f.write('foo') cmd = envelope.AttachCommand(path='~/foo') cmd.apply(ui) ui.current_buffer.envelope.attach.assert_called_with(testfile) def test_glob(self): """A test using a glob.""" ui = utilities.make_ui() with tempfile.TemporaryDirectory() as d: testfile1 = os.path.join(d, 'foo') testfile2 = os.path.join(d, 'far') for t in [testfile1, testfile2]: with open(t, 'w') as f: f.write('foo') cmd = envelope.AttachCommand(path=os.path.join(d, '*')) cmd.apply(ui) ui.current_buffer.envelope.attach.assert_has_calls( [mock.call(testfile1), mock.call(testfile2)], any_order=True) def test_no_match(self): """A test for a file that doesn't exist.""" ui = utilities.make_ui() with tempfile.TemporaryDirectory() as d: cmd = envelope.AttachCommand(path=os.path.join(d, 'doesnt-exist')) cmd.apply(ui) ui.notify.assert_called() class TestTagCommands(unittest.TestCase): def _test(self, tagstring, action, expected): """Common steps for envelope.TagCommand tests :param tagstring: the string to pass to the TagCommand :type tagstring: str :param action: the action to pass to the TagCommand :type action: str :param expected: the expected output to assert in the test :type expected: list(str) """ env = Envelope(tags=['one', 'two', 'three']) ui = utilities.make_ui() ui.current_buffer = mock.Mock() ui.current_buffer.envelope = env cmd = envelope.TagCommand(tags=tagstring, action=action) cmd.apply(ui) actual = env.tags self.assertListEqual(sorted(actual), sorted(expected)) def test_add_new_tags(self): self._test('four', 'add', ['one', 'two', 'three', 'four']) def test_adding_existing_tags_has_no_effect(self): self._test('one', 'add', ['one', 'two', 'three']) def test_remove_existing_tags(self): self._test('one', 'remove', ['two', 'three']) def test_remove_non_existing_tags_has_no_effect(self): self._test('four', 'remove', ['one', 'two', 'three']) def test_set_tags(self): self._test('a,b,c', 'set', ['a', 'b', 'c']) def test_toggle_will_remove_existing_tags(self): self._test('one', 'toggle', ['two', 'three']) def test_toggle_will_add_new_tags(self): self._test('four', 'toggle', ['one', 'two', 'three', 'four']) def test_toggle_can_remove_and_add_in_one_run(self): self._test('one,four', 'toggle', ['two', 'three', 'four']) class TestSignCommand(unittest.TestCase): """Tests for the SignCommand class.""" @staticmethod def _make_ui_mock(): """Create a mock for the ui and envelope and return them.""" envelope = Envelope() envelope['From'] = 'foo ' envelope.sign = mock.sentinel.default envelope.sign_key = mock.sentinel.default ui = utilities.make_ui(current_buffer=mock.Mock(envelope=envelope)) return envelope, ui @mock.patch('alot.commands.envelope.crypto.get_key', mock.Mock(return_value=mock.sentinel.keyid)) def test_apply_keyid_success(self): """If there is a valid keyid then key and to sign should be set. """ env, ui = self._make_ui_mock() # The actual keyid doesn't matter, since it'll be mocked anyway cmd = envelope.SignCommand(action='sign', keyid=['a']) cmd.apply(ui) self.assertTrue(env.sign) self.assertEqual(env.sign_key, mock.sentinel.keyid) @mock.patch('alot.commands.envelope.crypto.get_key', mock.Mock(side_effect=GPGProblem('sentinel', 0))) def test_apply_keyid_gpgproblem(self): """If there is an invalid keyid then the signing key and to sign should be set to false and default. """ env, ui = self._make_ui_mock() # The actual keyid doesn't matter, since it'll be mocked anyway cmd = envelope.SignCommand(action='sign', keyid=['a']) cmd.apply(ui) self.assertFalse(env.sign) self.assertEqual(env.sign_key, mock.sentinel.default) ui.notify.assert_called_once() @mock.patch('alot.commands.envelope.settings.account_matching_address', mock.Mock(side_effect=NoMatchingAccount)) def test_apply_no_keyid_nomatchingaccount(self): """If there is a nokeyid and no account can be found to match the From, then the envelope should not be marked to sign. """ env, ui = self._make_ui_mock() # The actual keyid doesn't matter, since it'll be mocked anyway cmd = envelope.SignCommand(action='sign', keyid=None) cmd.apply(ui) self.assertFalse(env.sign) self.assertEqual(env.sign_key, mock.sentinel.default) ui.notify.assert_called_once() def test_apply_no_keyid_no_gpg_key(self): """If there is a nokeyid and the account has no gpg key then the signing key and to sign should be set to false and default. """ env, ui = self._make_ui_mock() env.account = mock.Mock(gpg_key=None) cmd = envelope.SignCommand(action='sign', keyid=None) cmd.apply(ui) self.assertFalse(env.sign) self.assertEqual(env.sign_key, mock.sentinel.default) ui.notify.assert_called_once() def test_apply_no_keyid_default(self): """If there is no keyid and the account has a gpg key, then that should be used. """ env, ui = self._make_ui_mock() env.account = mock.Mock(gpg_key='sentinel') cmd = envelope.SignCommand(action='sign', keyid=None) cmd.apply(ui) self.assertTrue(env.sign) self.assertEqual(env.sign_key, 'sentinel') @mock.patch('alot.commands.envelope.crypto.get_key', mock.Mock(return_value=mock.sentinel.keyid)) def test_apply_no_sign(self): """If signing with a valid keyid and valid key then set sign and sign_key. """ env, ui = self._make_ui_mock() # The actual keyid doesn't matter, since it'll be mocked anyway cmd = envelope.SignCommand(action='sign', keyid=['a']) cmd.apply(ui) self.assertTrue(env.sign) self.assertEqual(env.sign_key, mock.sentinel.keyid) @mock.patch('alot.commands.envelope.crypto.get_key', mock.Mock(return_value=mock.sentinel.keyid)) def test_apply_unsign(self): """Test that settingun sign sets the sign to False if all other conditions allow for it. """ env, ui = self._make_ui_mock() env.sign = True env.sign_key = mock.sentinel # The actual keyid doesn't matter, since it'll be mocked anyway cmd = envelope.SignCommand(action='unsign', keyid=['a']) cmd.apply(ui) self.assertFalse(env.sign) self.assertIs(env.sign_key, None) @mock.patch('alot.commands.envelope.crypto.get_key', mock.Mock(return_value=mock.sentinel.keyid)) def test_apply_togglesign(self): """Test that toggling changes the sign and sign_key as approriate if other condtiions allow for it """ env, ui = self._make_ui_mock() env.sign = True env.sign_key = mock.sentinel.keyid # The actual keyid doesn't matter, since it'll be mocked anyway # Test that togling from true to false works cmd = envelope.SignCommand(action='toggle', keyid=['a']) cmd.apply(ui) self.assertFalse(env.sign) self.assertIs(env.sign_key, None) # Test that toggling back to True works cmd.apply(ui) self.assertTrue(env.sign) self.assertIs(env.sign_key, mock.sentinel.keyid) def _make_local_settings(self): config = textwrap.dedent("""\ [accounts] [[default]] realname = foo address = foo@example.com sendmail_command = /bin/true """) # Allow settings.reload to work by not deleting the file until the end with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(config) self.addCleanup(os.unlink, f.name) # Set the gpg_key separately to avoid validation failures manager = SettingsManager() manager.read_config(f.name) manager.get_accounts()[0].gpg_key = mock.sentinel.gpg_key return manager def test_apply_from_email_only(self): """Test that a key can be derived using a 'From' header that contains only an email. If the from header is in the form "foo@example.com" and a key exists it should be used. """ manager = self._make_local_settings() env, ui = self._make_ui_mock() env.headers = {'From': ['foo@example.com']} cmd = envelope.SignCommand(action='sign') with mock.patch('alot.commands.envelope.settings', manager): cmd.apply(ui) self.assertTrue(env.sign) self.assertIs(env.sign_key, mock.sentinel.gpg_key) def test_apply_from_user_and_email(self): """This tests that a gpg key can be derived using a 'From' header that contains a realname-email combo. If the header is in the form "Foo ", a key should be derived. See issue #1113 """ manager = self._make_local_settings() env, ui = self._make_ui_mock() cmd = envelope.SignCommand(action='sign') with mock.patch('alot.commands.envelope.settings', manager): cmd.apply(ui) self.assertTrue(env.sign) self.assertIs(env.sign_key, mock.sentinel.gpg_key) class TestSendCommand(unittest.TestCase): """Tests for the SendCommand class.""" mail = textwrap.dedent("""\ From: foo@example.com To: bar@example.com Subject: FooBar Foo Bar Baz """) class MockedAccount(Account): def __init__(self): super().__init__('foo@example.com') async def send_mail(self, mail): pass @utilities.async_test async def test_account_matching_address_with_str(self): cmd = envelope.SendCommand(mail=self.mail) account = mock.Mock(wraps=self.MockedAccount()) with mock.patch( 'alot.commands.envelope.settings.account_matching_address', mock.Mock(return_value=account)) as account_matching_address: await cmd.apply(mock.Mock()) account_matching_address.assert_called_once_with('foo@example.com', return_default=True) # check that the apply did run through till the end. account.send_mail.assert_called_once_with(self.mail) @utilities.async_test async def test_account_matching_address_with_email_message(self): mail = email.message_from_string(self.mail) cmd = envelope.SendCommand(mail=mail) account = mock.Mock(wraps=self.MockedAccount()) with mock.patch( 'alot.commands.envelope.settings.account_matching_address', mock.Mock(return_value=account)) as account_matching_address: await cmd.apply(mock.Mock()) account_matching_address.assert_called_once_with('foo@example.com', return_default=True) # check that the apply did run through till the end. account.send_mail.assert_called_once_with(mail) alot-0.11/tests/commands/test_global.py000066400000000000000000000160061466311112200202060ustar00rootroot00000000000000# encoding=utf-8 # Copyright © 2017-2018 Dylan Baker # # 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 . """Tests for global commands.""" import os import tempfile import unittest from unittest import mock from alot.commands import globals as g_commands from .. import utilities class Stop(Exception): """exception for stopping testing of giant unmanagable functions.""" pass class TestComposeCommand(unittest.TestCase): """Tests for the compose command.""" @staticmethod def _make_envelope_mock(): envelope = mock.Mock() envelope.headers = {'From': 'foo '} envelope.get = envelope.headers.get envelope.sign_key = None envelope.sign = False return envelope @staticmethod def _make_account_mock( sign_by_default=True, gpg_key=mock.sentinel.gpg_key): account = mock.Mock() account.sign_by_default = sign_by_default account.gpg_key = gpg_key account.signature = None return account def test_set_gpg_sign_by_default_okay(self): envelope = self._make_envelope_mock() envelope.account = self._make_account_mock() cmd = g_commands.ComposeCommand(envelope=envelope) cmd._set_gpg_sign(mock.Mock()) self.assertTrue(envelope.sign) self.assertIs(envelope.sign_key, mock.sentinel.gpg_key) def test_set_gpg_sign_by_default_false_doesnt_set_key(self): envelope = self._make_envelope_mock() envelope.account = self._make_account_mock(sign_by_default=False) cmd = g_commands.ComposeCommand(envelope=envelope) cmd._set_gpg_sign(mock.Mock()) self.assertFalse(envelope.sign) self.assertIs(envelope.sign_key, None) def test_set_gpg_sign_by_default_but_no_key(self): envelope = self._make_envelope_mock() envelope.account = self._make_account_mock(gpg_key=None) cmd = g_commands.ComposeCommand(envelope=envelope) cmd._set_gpg_sign(mock.Mock()) self.assertFalse(envelope.sign) self.assertIs(envelope.sign_key, None) def test_get_template_decode(self): subject = 'This is a täßϑ subject.' to = 'recipient@mail.com' _from = 'foo.bar@mail.fr' body = 'Body\n地初店会継思識棋御招告外児山望掲領環。\n€mail body €nd.' with tempfile.NamedTemporaryFile('wb', delete=False) as f: txt = 'Subject: {}\nTo: {}\nFrom: {}\n{}'.format(subject, to, _from, body) f.write(txt.encode('utf-8')) self.addCleanup(os.unlink, f.name) cmd = g_commands.ComposeCommand(template=f.name) cmd._set_envelope() cmd._get_template(mock.Mock()) self.assertEqual({'To': [to], 'From': [_from], 'Subject': [subject]}, cmd.envelope.headers) self.assertEqual(body, cmd.envelope.body_txt) class TestExternalCommand(unittest.TestCase): @utilities.async_test async def test_no_spawn_no_stdin_success(self): ui = utilities.make_ui() cmd = g_commands.ExternalCommand('true', refocus=False) await cmd.apply(ui) ui.notify.assert_not_called() @utilities.async_test async def test_no_spawn_stdin_success(self): ui = utilities.make_ui() cmd = g_commands.ExternalCommand("awk '{ exit $0 }'", stdin='0', refocus=False) await cmd.apply(ui) ui.notify.assert_not_called() @utilities.async_test async def test_no_spawn_no_stdin_attached(self): ui = utilities.make_ui() cmd = g_commands.ExternalCommand('test -p /dev/stdin', refocus=False) await cmd.apply(ui) ui.notify.assert_called_once_with( 'editor has exited with error code 1 -- No stderr output', priority='error') @utilities.async_test async def test_no_spawn_stdin_attached(self): ui = utilities.make_ui() cmd = g_commands.ExternalCommand( "test -p /dev/stdin", stdin='0', refocus=False) await cmd.apply(ui) ui.notify.assert_not_called() @utilities.async_test async def test_no_spawn_failure(self): ui = utilities.make_ui() cmd = g_commands.ExternalCommand('false', refocus=False) await cmd.apply(ui) ui.notify.assert_called_once_with( 'editor has exited with error code 1 -- No stderr output', priority='error') @utilities.async_test @mock.patch( 'alot.commands.globals.settings.get', mock.Mock(return_value='')) @mock.patch.dict(os.environ, {'DISPLAY': ':0'}) async def test_spawn_no_stdin_success(self): ui = utilities.make_ui() cmd = g_commands.ExternalCommand('true', refocus=False, spawn=True) await cmd.apply(ui) ui.notify.assert_not_called() @utilities.async_test @mock.patch( 'alot.commands.globals.settings.get', mock.Mock(return_value='')) @mock.patch.dict(os.environ, {'DISPLAY': ':0'}) async def test_spawn_stdin_success(self): ui = utilities.make_ui() cmd = g_commands.ExternalCommand( "awk '{ exit $0 }'", stdin='0', refocus=False, spawn=True) await cmd.apply(ui) ui.notify.assert_not_called() @utilities.async_test @mock.patch( 'alot.commands.globals.settings.get', mock.Mock(return_value='')) @mock.patch.dict(os.environ, {'DISPLAY': ':0'}) async def test_spawn_failure(self): ui = utilities.make_ui() cmd = g_commands.ExternalCommand('false', refocus=False, spawn=True) await cmd.apply(ui) ui.notify.assert_called_once_with( 'editor has exited with error code 1 -- No stderr output', priority='error') class TestCallCommand(unittest.TestCase): @utilities.async_test async def test_synchronous_call(self): ui = mock.Mock() cmd = g_commands.CallCommand('ui()') await cmd.apply(ui) ui.assert_called_once() @utilities.async_test async def test_async_call(self): async def func(obj): obj() ui = mock.Mock() hooks = mock.Mock() hooks.ui = None hooks.func = func with mock.patch('alot.commands.globals.settings.hooks', hooks): cmd = g_commands.CallCommand('hooks.func(ui)') await cmd.apply(ui) ui.assert_called_once() alot-0.11/tests/commands/test_init.py000066400000000000000000000031261466311112200177100ustar00rootroot00000000000000# encoding=utf-8 """Test suite for alot.commands.__init__ module.""" import argparse import unittest from unittest import mock from alot import commands from alot.commands import thread # Good descriptive test names often don't fit PEP8, which is meant to cover # functions meant to be called by humans. # pylint: disable=invalid-name # These are tests, don't worry about names like "foo" and "bar" # pylint: disable=blacklisted-name class TestLookupCommand(unittest.TestCase): def test_look_up_save_attachment_command_in_thread_mode(self): cmd, parser, kwargs = commands.lookup_command('save', 'thread') # TODO do some more tests with these return values self.assertEqual(cmd, thread.SaveAttachmentCommand) self.assertIsInstance(parser, argparse.ArgumentParser) self.assertDictEqual(kwargs, {}) class TestCommandFactory(unittest.TestCase): def test_create_save_attachment_command_with_arguments(self): cmd = commands.commandfactory('save --all /foo', mode='thread') self.assertIsInstance(cmd, thread.SaveAttachmentCommand) self.assertTrue(cmd.all) self.assertEqual(cmd.path, '/foo') class TestRegisterCommand(unittest.TestCase): """Tests for the registerCommand class.""" def test_registered(self): """using registerCommand adds to the COMMANDS dict.""" with mock.patch('alot.commands.COMMANDS', {'foo': {}}): @commands.registerCommand('foo', 'test') def foo(): # pylint: disable=unused-variable pass self.assertIn('test', commands.COMMANDS['foo']) alot-0.11/tests/commands/test_thread.py000066400000000000000000000156171466311112200202240ustar00rootroot00000000000000# encoding=utf-8 # Copyright (C) 2017 Lucas Hoffmann # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file """Test suite for alot.commands.thread module.""" import email import unittest from unittest import mock from alot.commands import thread from alot.account import Account # Good descriptive test names often don't fit PEP8, which is meant to cover # functions meant to be called by humans. # pylint: disable=invalid-name # These are tests, don't worry about names like "foo" and "bar" # pylint: disable=blacklisted-name class _AccountTestClass(Account): """Implements stubs for ABC methods.""" def send_mail(self, mail): pass class TestDetermineSender(unittest.TestCase): header_priority = ["From", "To", "Cc", "Envelope-To", "X-Envelope-To", "Delivered-To"] mailstring = '\n'.join([ "From: from@example.com", "To: to@example.com", "Cc: cc@example.com", "Envelope-To: envelope-to@example.com", "X-Envelope-To: x-envelope-to@example.com", "Delivered-To: delivered-to@example.com", "Subject: Alot test", "\n", "Some content", ]) mail = email.message_from_string(mailstring) def _test(self, accounts=(), expected=(), mail=None, header_priority=None, force_realname=False, force_address=False): """This method collects most of the steps that need to be done for most tests. Especially a closure to mock settings.get and a mock for settings.get_accounts are set up.""" mail = self.mail if mail is None else mail header_priority = self.header_priority if header_priority is None \ else header_priority def settings_get(arg): """Mock function for setting.get()""" if arg == "reply_account_header_priority": return header_priority elif arg.endswith('_force_realname'): return force_realname elif arg.endswith('_force_address'): return force_address with mock.patch('alot.commands.thread.settings.get_accounts', mock.Mock(return_value=accounts)): with mock.patch('alot.commands.thread.settings.get', settings_get): actual = thread.determine_sender(mail) self.assertTupleEqual(actual, expected) def test_assert_that_some_accounts_are_defined(self): with mock.patch('alot.commands.thread.settings.get_accounts', mock.Mock(return_value=[])) as cm1: with self.assertRaises(AssertionError) as cm2: thread.determine_sender(None) expected = ('no accounts set!',) cm1.assert_called_once_with() self.assertTupleEqual(cm2.exception.args, expected) def test_default_account_is_used_if_no_match_is_found(self): account1 = _AccountTestClass(address='foo@example.com') account2 = _AccountTestClass(address='bar@example.com') expected = ('foo@example.com', account1) self._test(accounts=[account1, account2], expected=expected) def test_matching_address_and_account_are_returned(self): account1 = _AccountTestClass(address='foo@example.com') account2 = _AccountTestClass(address='to@example.com') account3 = _AccountTestClass(address='bar@example.com') expected = ('to@example.com', account2) self._test(accounts=[account1, account2, account3], expected=expected) def test_force_realname_has_real_name_in_returned_address_if_defined(self): account1 = _AccountTestClass(address='foo@example.com') account2 = _AccountTestClass(address='to@example.com', realname='Bar') account3 = _AccountTestClass(address='baz@example.com') expected = ('Bar ', account2) self._test(accounts=[account1, account2, account3], expected=expected, force_realname=True) def test_doesnt_fail_with_force_realname_if_real_name_not_defined(self): account1 = _AccountTestClass(address='foo@example.com') account2 = _AccountTestClass(address='to@example.com') account3 = _AccountTestClass(address='bar@example.com') expected = ('to@example.com', account2) self._test(accounts=[account1, account2, account3], expected=expected, force_realname=True) def test_with_force_address_main_address_is_always_used(self): # In python 3.4 this and the next test could be written as subtests. account1 = _AccountTestClass(address='foo@example.com') account2 = _AccountTestClass(address='bar@example.com', aliases=['to@example.com']) account3 = _AccountTestClass(address='bar@example.com') expected = ('bar@example.com', account2) self._test(accounts=[account1, account2, account3], expected=expected, force_address=True) def test_without_force_address_matching_address_is_used(self): # In python 3.4 this and the previous test could be written as # subtests. account1 = _AccountTestClass(address='foo@example.com') account2 = _AccountTestClass(address='bar@example.com', aliases=['to@example.com']) account3 = _AccountTestClass(address='baz@example.com') expected = ('to@example.com', account2) self._test(accounts=[account1, account2, account3], expected=expected, force_address=False) def test_uses_to_header_if_present(self): account1 = _AccountTestClass(address='foo@example.com') account2 = _AccountTestClass(address='to@example.com') account3 = _AccountTestClass(address='bar@example.com') expected = ('to@example.com', account2) self._test(accounts=[account1, account2, account3], expected=expected) def test_header_order_is_more_important_than_accounts_order(self): account1 = _AccountTestClass(address='cc@example.com') account2 = _AccountTestClass(address='to@example.com') account3 = _AccountTestClass(address='bcc@example.com') expected = ('to@example.com', account2) self._test(accounts=[account1, account2, account3], expected=expected) def test_accounts_can_be_found_by_alias_regex_setting(self): account1 = _AccountTestClass(address='foo@example.com') account2 = _AccountTestClass(address='to@example.com', alias_regexp=r'to\+.*@example.com') account3 = _AccountTestClass(address='bar@example.com') mailstring = self.mailstring.replace('to@example.com', 'to+some_tag@example.com') mail = email.message_from_string(mailstring) expected = ('to+some_tag@example.com', account2) self._test(accounts=[account1, account2, account3], expected=expected, mail=mail) alot-0.11/tests/commands/utils_tests.py000066400000000000000000000161101466311112200202650ustar00rootroot00000000000000# encoding=utf-8 # Copyright © 2017-2018 Dylan Baker # # 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 . import tempfile import os import shutil import unittest from unittest import mock import gpg from alot import crypto from alot import errors from alot.commands import utils from alot.db.envelope import Envelope from .. import utilities MOD_CLEAN = utilities.ModuleCleanup() # A useful single fingerprint for tests that only care about one key. This # key will not be ambiguous FPR = "F74091D4133F87D56B5D343C1974EC55FBC2D660" # Some additional keys, these keys may be ambigiuos EXTRA_FPRS = [ "DD19862809A7573A74058FF255937AFBB156245D", "2071E9C8DB4EF5466F4D233CF730DF92C4566CE7", ] DEVNULL = open('/dev/null', 'w') MOD_CLEAN.add_cleanup(DEVNULL.close) @MOD_CLEAN.wrap_setup def setUpModule(): home = tempfile.mkdtemp() MOD_CLEAN.add_cleanup(shutil.rmtree, home) mock_home = mock.patch.dict(os.environ, {'GNUPGHOME': home}) mock_home.start() MOD_CLEAN.add_cleanup(mock_home.stop) with gpg.core.Context(armor=True) as ctx: # Add the public and private keys. They have no password search_dir = os.path.join( os.path.dirname(__file__), '../static/gpg-keys') for each in os.listdir(search_dir): if os.path.splitext(each)[1] == '.gpg': with open(os.path.join(search_dir, each)) as f: ctx.op_import(f) @MOD_CLEAN.wrap_teardown def tearDownModule(): pass class TestGetKeys(unittest.TestCase): # pylint: disable=protected-access @utilities.async_test async def test_get_keys(self): """Test that getting keys works when all keys are present.""" expected = crypto.get_key(FPR, validate=True, encrypt=True, signed_only=False) ui = utilities.make_ui() ids = [FPR] actual = await utils._get_keys(ui, ids) self.assertIn(FPR, actual) self.assertEqual(actual[FPR].fpr, expected.fpr) @utilities.async_test async def test_get_keys_missing(self): """Test that getting keys works when some keys are missing.""" expected = crypto.get_key(FPR, validate=True, encrypt=True, signed_only=False) ui = utilities.make_ui() ids = [FPR, "6F6B15509CF8E59E6E469F327F438280EF8D349F"] actual = await utils._get_keys(ui, ids) self.assertIn(FPR, actual) self.assertEqual(actual[FPR].fpr, expected.fpr) @utilities.async_test async def test_get_keys_signed_only(self): """Test gettings keys when signed only is required.""" ui = utilities.make_ui() ids = [FPR] actual = await utils._get_keys(ui, ids, signed_only=True) self.assertEqual(actual, {}) @utilities.async_test async def test_get_keys_ambiguous(self): """Test gettings keys when when the key is ambiguous.""" key = crypto.get_key( FPR, validate=True, encrypt=True, signed_only=False) ui = utilities.make_ui() # Creat a ui.choice object that can satisfy asyncio, but can also be # queried for calls as a mock async def choice(*args, **kwargs): return None ui.choice = mock.Mock(wraps=choice) ids = [FPR] with mock.patch('alot.commands.utils.crypto.get_key', mock.Mock(side_effect=errors.GPGProblem( 'test', errors.GPGCode.AMBIGUOUS_NAME))): with mock.patch('alot.commands.utils.crypto.list_keys', mock.Mock(return_value=[key])): await utils._get_keys(ui, ids, signed_only=False) ui.choice.assert_called_once() class _Account(object): def __init__(self, encrypt_to_self=True, gpg_key=None): self.encrypt_to_self = encrypt_to_self self.gpg_key = gpg_key class TestSetEncrypt(unittest.TestCase): @utilities.async_test async def test_get_keys_from_to(self): ui = utilities.make_ui() envelope = Envelope() envelope['To'] = 'ambig@example.com, test@example.com' await utils.update_keys(ui, envelope) self.assertTrue(envelope.encrypt) self.assertCountEqual( [f.fpr for f in envelope.encrypt_keys.values()], [crypto.get_key(FPR).fpr, crypto.get_key(EXTRA_FPRS[0]).fpr]) @utilities.async_test async def test_get_keys_from_cc(self): ui = utilities.make_ui() envelope = Envelope() envelope['Cc'] = 'ambig@example.com, test@example.com' await utils.update_keys(ui, envelope) self.assertTrue(envelope.encrypt) self.assertCountEqual( [f.fpr for f in envelope.encrypt_keys.values()], [crypto.get_key(FPR).fpr, crypto.get_key(EXTRA_FPRS[0]).fpr]) @utilities.async_test async def test_get_partial_keys(self): ui = utilities.make_ui() envelope = Envelope() envelope['Cc'] = 'foo@example.com, test@example.com' await utils.update_keys(ui, envelope) self.assertTrue(envelope.encrypt) self.assertCountEqual( [f.fpr for f in envelope.encrypt_keys.values()], [crypto.get_key(FPR).fpr]) @utilities.async_test async def test_get_no_keys(self): ui = utilities.make_ui() envelope = Envelope() envelope['To'] = 'foo@example.com' await utils.update_keys(ui, envelope) self.assertFalse(envelope.encrypt) self.assertEqual(envelope.encrypt_keys, {}) @utilities.async_test async def test_encrypt_to_self_true(self): ui = utilities.make_ui() envelope = Envelope() envelope['From'] = 'test@example.com' envelope['To'] = 'ambig@example.com' gpg_key = crypto.get_key(FPR) account = _Account(encrypt_to_self=True, gpg_key=gpg_key) envelope.account = account await utils.update_keys(ui, envelope) self.assertTrue(envelope.encrypt) self.assertIn(FPR, envelope.encrypt_keys) self.assertEqual(gpg_key, envelope.encrypt_keys[FPR]) @utilities.async_test async def test_encrypt_to_self_false(self): ui = utilities.make_ui() envelope = Envelope() envelope['From'] = 'test@example.com' envelope['To'] = 'ambig@example.com' gpg_key = crypto.get_key(FPR) account = _Account(encrypt_to_self=False, gpg_key=gpg_key) envelope.account = account await utils.update_keys(ui, envelope) self.assertTrue(envelope.encrypt) self.assertNotIn(FPR, envelope.encrypt_keys) alot-0.11/tests/db/000077500000000000000000000000001466311112200141165ustar00rootroot00000000000000alot-0.11/tests/db/__init__.py000066400000000000000000000000001466311112200162150ustar00rootroot00000000000000alot-0.11/tests/db/test_envelope.py000066400000000000000000000115561466311112200173540ustar00rootroot00000000000000# Copyright © 2017 Lucas Hoffmann # Copyright © 2018 Dylan Baker # # 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 . import email.parser import email.policy import os import tempfile import unittest from unittest import mock import sys from alot.db import envelope from alot.account import Account SETTINGS = { 'user_agent': 'agent', } test_account = Account('batman@batcave.org', message_id_domain='example.com') class TestEnvelope(unittest.TestCase): def assertEmailEqual(self, first, second): with self.subTest('body'): self.assertEqual(first.is_multipart(), second.is_multipart()) if not first.is_multipart(): self.assertEqual(first.get_payload(), second.get_payload()) else: for f, s in zip(first.walk(), second.walk()): if f.is_multipart() or s.is_multipart(): self.assertEqual(first.is_multipart(), second.is_multipart()) else: self.assertEqual(f.get_payload(), s.get_payload()) with self.subTest('headers'): self.assertListEqual(first.values(), second.values()) def test_setitem_stores_text_unchanged(self): "Just ensure that the value is set and unchanged" e = envelope.Envelope(account=test_account) e['Subject'] = 'sm\xf8rebr\xf8d' self.assertEqual(e['Subject'], 'sm\xf8rebr\xf8d') def _test_mail(self, envelope): mail = envelope.construct_mail() raw = mail.as_string(policy=email.policy.SMTP) actual = email.parser.Parser().parsestr(raw) self.assertEmailEqual(mail, actual) @mock.patch('alot.db.envelope.settings', SETTINGS) def test_construct_mail_simple(self): """Very simple envelope with a To, From, Subject, and body.""" headers = { 'From': 'foo@example.com', 'To': 'bar@example.com', 'Subject': 'Test email', } e = envelope.Envelope(account=test_account, headers={k: [v] for k, v in headers.items()}, bodytext='Test') self._test_mail(e) @mock.patch('alot.db.envelope.settings', SETTINGS) def test_construct_mail_with_attachment(self): """Very simple envelope with a To, From, Subject, body and attachment. """ headers = { 'From': 'foo@example.com', 'To': 'bar@example.com', 'Subject': 'Test email', } e = envelope.Envelope(account=test_account, headers={k: [v] for k, v in headers.items()}, bodytext='Test') with tempfile.NamedTemporaryFile(mode='wt', delete=False) as f: f.write('blah') self.addCleanup(os.unlink, f.name) e.attach(f.name) self._test_mail(e) @mock.patch('alot.db.envelope.settings', SETTINGS) def test_parse_template(self): """Tests multi-line header and body parsing""" raw = ( 'From: foo@example.com\n' 'To: bar@example.com,\n' ' baz@example.com\n' 'Subject: Fwd: Test email\n' '\n' 'Some body content: which is not a header.\n' ) envlp = envelope.Envelope(account=test_account) envlp.parse_template(raw) self.assertDictEqual(envlp.headers, { 'From': ['foo@example.com'], 'To': ['bar@example.com, baz@example.com'], 'Subject': ['Fwd: Test email'] }) self.assertEqual(envlp.body_txt, 'Some body content: which is not a header.') @mock.patch('alot.db.envelope.settings', SETTINGS) def test_construct_encoding(self): headers = { 'From': 'foo@example.com', 'To': 'bar@example.com', 'Subject': 'Test email héhé', } e = envelope.Envelope(account=test_account, headers={k: [v] for k, v in headers.items()}, bodytext='Test') mail = e.construct_mail() raw = mail.as_string(policy=email.policy.SMTP, maxheaderlen=sys.maxsize) actual = email.parser.Parser().parsestr(raw) self.assertEqual('Test email =?utf-8?b?aMOpaMOp?=', actual['Subject']) alot-0.11/tests/db/test_manager.py000066400000000000000000000036531466311112200171500ustar00rootroot00000000000000# Copyright (C) 2018 Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file """Test suite for alot.db.manager module.""" import os import shutil import tempfile import textwrap from unittest import mock from alot.db.manager import DBManager from alot.settings.const import settings from notmuch2 import Database from .. import utilities class TestDBManager(utilities.TestCaseClassCleanup): @classmethod def setUpClass(cls): # create temporary notmuch config with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ [maildir] synchronize_flags = true """)) cls.notmuch_config_path = f.name cls.addClassCleanup(os.unlink, f.name) # define an empty notmuch database in a temporary directory cls.dbpath = tempfile.mkdtemp() cls.db = Database.create(path=cls.dbpath) cls.db.close() cls.manager = DBManager(cls.dbpath) # clean up temporary database cls.addClassCleanup(shutil.rmtree, cls.dbpath) # let global settings manager read our temporary notmuch config settings.read_notmuch_config(cls.notmuch_config_path) def test_save_named_query(self): with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ [maildir] synchronize_flags = true """)) self.addCleanup(os.unlink, f.name) with mock.patch.dict('os.environ', NOTMUCH_CONFIG=f.name): alias = 'key' querystring = 'query string' self.manager.save_named_query(alias, querystring) self.manager.flush() named_queries_dict = self.manager.get_named_queries() self.assertDictEqual(named_queries_dict, {alias: querystring}) alot-0.11/tests/db/test_message.py000066400000000000000000000100771466311112200171600ustar00rootroot00000000000000# encoding=utf-8 # Copyright © 2017 Dylan Baker # # 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 . import unittest from unittest import mock from alot import account from alot.db import message class MockNotmuchMessage(object): """An object that looks very much like a notmuch message. All public instance variables that are not part of the notmuch Message class are prefaced with mock. """ class MockProperties(object): def getall(self, *args, **kwargs): return [] def __init__(self, headers=None, tags=None): self.mock_headers = headers or {} self.mock_message_id = 'message id' self.mock_thread_id = 'thread id' self.mock_date = 0 self.mock_filename = 'filename' self.mock_tags = tags or [] def header(self, field): return self.mock_headers.get(field, '') @property def messageid(self): return self.mock_message_id @property def threadid(self): return self.mock_thread_id @property def date(self): return self.mock_date @property def path(self): return self.mock_filename @property def tags(self): return self.mock_tags @property def properties(self): return MockNotmuchMessage.MockProperties() class TestMessage(unittest.TestCase): def test_get_author_email_only(self): """Message._from is populated using the 'From' header when only an email address is provided. """ msg = message.Message(mock.Mock(), MockNotmuchMessage({'From': 'user@example.com'})) self.assertEqual(msg.get_author(), ('', 'user@example.com')) def test_get_author_name_and_email(self): """Message._from is populated using the 'From' header when an email and name are provided. """ msg = message.Message( mock.Mock(), MockNotmuchMessage({'From': '"User Name" '})) self.assertEqual(msg.get_author(), ('User Name', 'user@example.com')) def test_get_author_sender(self): """Message._from is populated using the 'Sender' header when no 'From' header is present. """ msg = message.Message( mock.Mock(), MockNotmuchMessage({'Sender': '"User Name" '})) self.assertEqual(msg.get_author(), ('User Name', 'user@example.com')) def test_get_author_no_name_draft(self): """Message._from is populated from the default account if the draft tag is present. """ acc = mock.Mock() acc.address = account.Address('user', 'example.com') acc.realname = 'User Name' with mock.patch('alot.db.message.settings.get_accounts', mock.Mock(return_value=[acc])): msg = message.Message( mock.Mock(), MockNotmuchMessage(tags=['draft'])) self.assertEqual(msg.get_author(), ('User Name', 'user@example.com')) def test_get_author_no_name(self): """Message._from is set to 'Unkown' if there is no relavent header and the message is not a draft. """ acc = mock.Mock() acc.address = account.Address('user', 'example.com') acc.realname = 'User Name' with mock.patch('alot.db.message.settings.get_accounts', mock.Mock(return_value=[acc])): msg = message.Message(mock.Mock(), MockNotmuchMessage()) self.assertEqual(msg.get_author(), ('Unknown', '')) alot-0.11/tests/db/test_thread.py000066400000000000000000000052451466311112200170040ustar00rootroot00000000000000# encoding=utf-8 # Copyright © 2016 Dylan Baker # 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 . """Tests for the alot.db.thread module.""" import datetime import unittest from unittest import mock from alot.db import thread class TestThreadGetAuthor(unittest.TestCase): __patchers = [] @classmethod def setUpClass(cls): get_messages = [] for a, d in [('foo', datetime.datetime(datetime.MINYEAR, 1, day=21)), ('bar', datetime.datetime(datetime.MINYEAR, 1, day=17)), ('foo', datetime.datetime(datetime.MINYEAR, 1, day=14)), ('arf', datetime.datetime(datetime.MINYEAR, 1, 1, hour=1, minute=5)), ('oof', datetime.datetime(datetime.MINYEAR, 1, 1, hour=1, minute=10)), ('ooh', None)]: m = mock.Mock() m.get_date = mock.Mock(return_value=d) m.get_author = mock.Mock(return_value=a) get_messages.append(m) gm = mock.Mock() gm.keys = mock.Mock(return_value=get_messages) cls.__patchers.extend([ mock.patch('alot.db.thread.Thread.get_messages', new=mock.Mock(return_value=gm)), mock.patch('alot.db.thread.Thread.refresh', new=mock.Mock()), ]) for p in cls.__patchers: p.start() @classmethod def tearDownClass(cls): for p in reversed(cls.__patchers): p.stop() def setUp(self): # values are cached and each test needs it's own instance. self.thread = thread.Thread(mock.Mock(), mock.Mock()) def test_default(self): self.assertEqual( self.thread.get_authors(), ['arf', 'oof', 'foo', 'bar', 'ooh']) def test_latest_message(self): with mock.patch('alot.db.thread.settings.get', mock.Mock(return_value='latest_message')): self.assertEqual( self.thread.get_authors(), ['arf', 'oof', 'bar', 'foo', 'ooh']) alot-0.11/tests/db/test_utils.py000066400000000000000000001063311466311112200166730ustar00rootroot00000000000000# encoding: utf-8 # Copyright (C) 2017 Lucas Hoffmann # Copyright © 2017 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import base64 import codecs import email import email.header import email.mime.application import email.mime.multipart import email.policy import email.utils from email.message import EmailMessage import os import os.path from pathlib import Path import shutil import tempfile import unittest from unittest import mock import gpg from alot import crypto from alot.db import utils from alot.errors import GPGProblem from alot.account import Account from ..utilities import make_key, make_uid, TestCaseClassCleanup def set_basic_headers(mail): mail['Subject'] = 'Test email' mail['To'] = 'foo@example.com' mail['From'] = 'bar@example.com' class TestGetParams(unittest.TestCase): mailstring = '\n'.join([ 'From: me', 'To: you', 'Subject: header field capitalisation', 'Content-type: text/plain; charset=utf-8', 'X-Header: param=one; and=two; or=three', "X-Quoted: param=utf-8''%C3%9Cmlaut; second=plain%C3%9C", 'X-UPPERCASE: PARAM1=ONE; PARAM2=TWO' '\n', 'content' ]) mail = email.message_from_string(mailstring) def test_returns_content_type_parameters_by_default(self): actual = utils.get_params(self.mail) expected = {'text/plain': '', 'charset': 'utf-8'} self.assertDictEqual(actual, expected) def test_can_return_params_of_any_header_field(self): actual = utils.get_params(self.mail, header='x-header') expected = {'param': 'one', 'and': 'two', 'or': 'three'} self.assertDictEqual(actual, expected) @unittest.expectedFailure def test_parameters_are_decoded(self): actual = utils.get_params(self.mail, header='x-quoted') expected = {'param': 'Ümlaut', 'second': 'plain%C3%9C'} self.assertDictEqual(actual, expected) def test_parameters_names_are_converted_to_lowercase(self): actual = utils.get_params(self.mail, header='x-uppercase') expected = {'param1': 'ONE', 'param2': 'TWO'} self.assertDictEqual(actual, expected) def test_returns_empty_dict_if_header_not_present(self): actual = utils.get_params(self.mail, header='x-header-not-present') self.assertDictEqual(actual, dict()) def test_returns_failobj_if_header_not_present(self): failobj = [('my special failobj for the test', 'needs to be a pair!')] actual = utils.get_params(self.mail, header='x-header-not-present', failobj=failobj) expected = dict(failobj) self.assertEqual(actual, expected) class TestIsSubdirOf(unittest.TestCase): def test_both_paths_absolute_matching(self): superpath = '/a/b' subpath = '/a/b/c/d.rst' result = utils.is_subdir_of(subpath, superpath) self.assertTrue(result) def test_both_paths_absolute_not_matching(self): superpath = '/a/z' subpath = '/a/b/c/d.rst' result = utils.is_subdir_of(subpath, superpath) self.assertFalse(result) def test_both_paths_relative_matching(self): superpath = 'a/b' subpath = 'a/b/c/d.rst' result = utils.is_subdir_of(subpath, superpath) self.assertTrue(result) def test_both_paths_relative_not_matching(self): superpath = 'a/z' subpath = 'a/b/c/d.rst' result = utils.is_subdir_of(subpath, superpath) self.assertFalse(result) def test_relative_path_and_absolute_path_matching(self): superpath = 'a/b' subpath = os.path.join(os.getcwd(), 'a/b/c/d.rst') result = utils.is_subdir_of(subpath, superpath) self.assertTrue(result) class TestExtractHeader(unittest.TestCase): mailstring = '\n'.join([ 'From: me', 'To: you', 'Subject: header field capitalisation', 'Content-type: text/plain; charset=utf-8', 'X-Header: param=one; and=two; or=three', "X-Quoted: param=utf-8''%C3%9Cmlaut; second=plain%C3%9C", 'X-UPPERCASE: PARAM1=ONE; PARAM2=TWO' '\n', 'content' ]) mail = email.message_from_string(mailstring) def test_default_arguments_yield_all_headers(self): actual = utils.extract_headers(self.mail) # collect all lines until the first empty line, hence all header lines expected = [] for line in self.mailstring.splitlines(): if not line: break expected.append(line) expected = '\n'.join(expected) + '\n' self.assertEqual(actual, expected) def test_single_headers_can_be_retrieved(self): actual = utils.extract_headers(self.mail, ['from']) expected = 'from: me\n' self.assertEqual(actual, expected) def test_multible_headers_can_be_retrieved_in_predevined_order(self): headers = ['x-header', 'to', 'x-uppercase'] actual = utils.extract_headers(self.mail, headers) expected = 'x-header: param=one; and=two; or=three\nto: you\n' \ 'x-uppercase: PARAM1=ONE; PARAM2=TWO\n' self.assertEqual(actual, expected) def test_headers_can_be_retrieved_multible_times(self): headers = ['from', 'from'] actual = utils.extract_headers(self.mail, headers) expected = 'from: me\nfrom: me\n' self.assertEqual(actual, expected) def test_case_is_prserved_in_header_keys_but_irelevant(self): headers = ['FROM', 'from'] actual = utils.extract_headers(self.mail, headers) expected = 'FROM: me\nfrom: me\n' self.assertEqual(actual, expected) @unittest.expectedFailure def test_header_values_are_not_decoded(self): actual = utils.extract_headers(self.mail, ['x-quoted']) expected = "x-quoted: param=utf-8''%C3%9Cmlaut; second=plain%C3%9C\n", self.assertEqual(actual, expected) class TestDecodeHeader(unittest.TestCase): @staticmethod def _quote(unicode_string, encoding): """Turn a unicode string into a RFC2047 quoted ascii string :param unicode_string: the string to encode :type unicode_string: unicode :param encoding: the encoding to use, 'utf-8', 'iso-8859-1', ... :type encoding: str :returns: the encoded string :rtype: str """ string = unicode_string.encode(encoding) output = b'=?' + encoding.encode('ascii') + b'?Q?' for byte in string: output += b'=' + codecs.encode(bytes([byte]), 'hex').upper() return (output + b'?=').decode('ascii') @staticmethod def _base64(unicode_string, encoding): """Turn a unicode string into a RFC2047 base64 encoded ascii string :param unicode_string: the string to encode :type unicode_string: unicode :param encoding: the encoding to use, 'utf-8', 'iso-8859-1', ... :type encoding: str :returns: the encoded string :rtype: str """ string = unicode_string.encode(encoding) b64 = base64.encodebytes(string).strip() result_bytes = b'=?' + encoding.encode('utf-8') + b'?B?' + b64 + b'?=' result = result_bytes.decode('ascii') return result def _test(self, teststring, expected): actual = utils.decode_header(teststring) self.assertEqual(actual, expected) def test_non_ascii_strings_are_returned_as_unicode_directly(self): text = 'Nön ÄSCII string¡' self._test(text, text) def test_basic_utf_8_quoted(self): expected = 'ÄÖÜäöü' text = self._quote(expected, 'utf-8') self._test(text, expected) def test_basic_iso_8859_1_quoted(self): expected = 'ÄÖÜäöü' text = self._quote(expected, 'iso-8859-1') self._test(text, expected) def test_basic_windows_1252_quoted(self): expected = 'ÄÖÜäöü' text = self._quote(expected, 'windows-1252') self._test(text, expected) def test_basic_utf_8_base64(self): expected = 'ÄÖÜäöü' text = self._base64(expected, 'utf-8') self._test(text, expected) def test_basic_iso_8859_1_base64(self): expected = 'ÄÖÜäöü' text = self._base64(expected, 'iso-8859-1') self._test(text, expected) def test_basic_iso_1252_base64(self): expected = 'ÄÖÜäöü' text = self._base64(expected, 'windows-1252') self._test(text, expected) def test_quoted_words_can_be_interrupted(self): part = 'ÄÖÜäöü' text = self._base64(part, 'utf-8') + ' and ' + \ self._quote(part, 'utf-8') expected = 'ÄÖÜäöü and ÄÖÜäöü' self._test(text, expected) def test_different_encodings_can_be_mixed(self): part = 'ÄÖÜäöü' text = 'utf-8: ' + self._base64(part, 'utf-8') + \ ' again: ' + self._quote(part, 'utf-8') + \ ' latin1: ' + self._base64(part, 'iso-8859-1') + \ ' and ' + self._quote(part, 'iso-8859-1') expected = ( 'utf-8: ÄÖÜäöü ' 'again: ÄÖÜäöü ' 'latin1: ÄÖÜäöü and ÄÖÜäöü' ) self._test(text, expected) def test_tabs_are_expanded_to_align_with_eigth_spaces(self): text = 'tab: \t' expected = 'tab: ' self._test(text, expected) def test_newlines_are_not_touched_by_default(self): text = 'first\nsecond\n third\n fourth' expected = 'first\nsecond\n third\n fourth' self._test(text, expected) def test_continuation_newlines_can_be_normalized(self): text = 'first\nsecond\n third\n\tfourth\n \t fifth' expected = 'first\nsecond third fourth fifth' actual = utils.decode_header(text, normalize=True) self.assertEqual(actual, expected) def test_exchange_quotes_remain(self): # issue #1347 expected = '"Mouse, Michaël" ' text = self._quote(expected, 'utf-8') self._test(text, expected) class TestAddSignatureHeaders(unittest.TestCase): class FakeMail(object): def __init__(self): self.headers = [] def add_header(self, header, value): self.headers.append((header, value)) def check(self, key, valid, error_msg=''): mail = self.FakeMail() with mock.patch('alot.db.utils.crypto.get_key', mock.Mock(return_value=key)), \ mock.patch('alot.db.utils.crypto.check_uid_validity', mock.Mock(return_value=valid)): utils.add_signature_headers(mail, [mock.Mock(fpr='')], error_msg) return mail def test_length_0(self): mail = self.FakeMail() utils.add_signature_headers(mail, [], '') self.assertIn((utils.X_SIGNATURE_VALID_HEADER, 'False'), mail.headers) self.assertIn( (utils.X_SIGNATURE_MESSAGE_HEADER, 'Invalid: no signature found'), mail.headers) def test_valid(self): key = make_key() mail = self.check(key, True) self.assertIn((utils.X_SIGNATURE_VALID_HEADER, 'True'), mail.headers) self.assertIn( (utils.X_SIGNATURE_MESSAGE_HEADER, 'Valid: mocked'), mail.headers) def test_untrusted(self): key = make_key() mail = self.check(key, False) self.assertIn((utils.X_SIGNATURE_VALID_HEADER, 'True'), mail.headers) self.assertIn( (utils.X_SIGNATURE_MESSAGE_HEADER, 'Untrusted: mocked'), mail.headers) def test_unicode_as_bytes(self): mail = self.FakeMail() key = make_key() key.uids = [make_uid('andreá@example.com', uid='Andreá')] mail = self.check(key, True) self.assertIn((utils.X_SIGNATURE_VALID_HEADER, 'True'), mail.headers) self.assertIn( (utils.X_SIGNATURE_MESSAGE_HEADER, 'Valid: Andreá'), mail.headers) def test_error_message_unicode(self): mail = self.check(mock.Mock(), mock.Mock(), 'error message') self.assertIn((utils.X_SIGNATURE_VALID_HEADER, 'False'), mail.headers) self.assertIn( (utils.X_SIGNATURE_MESSAGE_HEADER, 'Invalid: error message'), mail.headers) def test_get_key_fails(self): mail = self.FakeMail() with mock.patch('alot.db.utils.crypto.get_key', mock.Mock(side_effect=GPGProblem('', 0))): utils.add_signature_headers(mail, [mock.Mock(fpr='')], '') self.assertIn((utils.X_SIGNATURE_VALID_HEADER, 'False'), mail.headers) self.assertIn( (utils.X_SIGNATURE_MESSAGE_HEADER, 'Untrusted: '), mail.headers) class TestMessageFromFile(TestCaseClassCleanup): @classmethod def setUpClass(cls): home = tempfile.mkdtemp() cls.addClassCleanup(lambda : shutil.rmtree(home, ignore_errors=True)) mock_home = mock.patch.dict(os.environ, {'GNUPGHOME': home}) mock_home.start() cls.addClassCleanup(mock_home.stop) with gpg.core.Context() as ctx: search_dir = os.path.join(os.path.dirname(__file__), '../static/gpg-keys') for each in os.listdir(search_dir): if os.path.splitext(each)[1] == '.gpg': with open(os.path.join(search_dir, each)) as f: ctx.op_import(f) cls.keys = [ ctx.get_key("DD19862809A7573A74058FF255937AFBB156245D")] def test_erase_alot_header_signature_valid(self): """Alot uses special headers for passing certain kinds of information, it's important that information isn't passed in from the original message as a way to trick the user. """ m = email.message.Message() m.add_header(utils.X_SIGNATURE_VALID_HEADER, 'Bad') message = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIs(message.get(utils.X_SIGNATURE_VALID_HEADER), None) def test_erase_alot_header_message(self): m = email.message.Message() m.add_header(utils.X_SIGNATURE_MESSAGE_HEADER, 'Bad') message = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIs(message.get(utils.X_SIGNATURE_MESSAGE_HEADER), None) def test_plain_mail(self): m = email.mime.text.MIMEText('This is some text', 'plain', 'utf-8') m['Subject'] = 'test' m['From'] = 'me' m['To'] = 'Nobody' message = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertEqual(message.get_payload(), 'This is some text') def _make_signed(self): """Create a signed message that is multipart/signed.""" text = b'This is some text' t = email.mime.text.MIMEText(text, 'plain', 'utf-8') _, sig = crypto.detached_signature_for( t.as_bytes(policy=email.policy.SMTP), self.keys) s = email.mime.application.MIMEApplication( sig, 'pgp-signature', email.encoders.encode_7or8bit) m = email.mime.multipart.MIMEMultipart('signed', None, [t, s]) m.set_param('protocol', 'application/pgp-signature') m.set_param('micalg', 'pgp-sha256') return m def test_signed_headers_included(self): """Headers are added to the message.""" m = self._make_signed() m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIn(utils.X_SIGNATURE_VALID_HEADER, m) self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) def test_signed_valid(self): """Test that the signature is valid.""" m = self._make_signed() m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertEqual(m[utils.X_SIGNATURE_VALID_HEADER], 'True') def test_signed_correct_from(self): """Test that the signature is valid.""" m = self._make_signed() m = utils.decrypted_message_from_bytes(m.as_bytes()) # Don't test for valid/invalid since that might change self.assertIn( 'ambig ', m[utils.X_SIGNATURE_MESSAGE_HEADER]) def test_signed_wrong_mimetype_second_payload(self): m = self._make_signed() m.get_payload(1).set_type('text/plain') m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIn('expected Content-Type: ', m[utils.X_SIGNATURE_MESSAGE_HEADER]) def test_signed_wrong_micalg(self): m = self._make_signed() m.set_param('micalg', 'foo') m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIn('expected micalg=pgp-...', m[utils.X_SIGNATURE_MESSAGE_HEADER]) def test_signed_micalg_cap(self): """The micalg parameter should be normalized to lower case. From RFC 3156 § 5 The "micalg" parameter for the "application/pgp-signature" protocol MUST contain exactly one hash-symbol of the format "pgp-", where identifies the Message Integrity Check (MIC) algorithm used to generate the signature. Hash-symbols are constructed from the text names registered in [1] or according to the mechanism defined in that document by converting the text name to lower case and prefixing it with the four characters "pgp-". The spec is pretty clear that this is supposed to be lower cased. """ m = self._make_signed() m.set_param('micalg', 'PGP-SHA1') m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIn('expected micalg=pgp-', m[utils.X_SIGNATURE_MESSAGE_HEADER]) def test_signed_more_than_two_messages(self): """Per the spec only 2 payloads may be encapsulated inside the multipart/signed payload, while it might be nice to cover more than 2 payloads (Postel's law), it would introduce serious complexity since we would also need to cover those payloads being misordered. Since getting the right number of payloads and getting them in the right order should be fairly easy to implement correctly enforcing that there are only two payloads seems reasonable. """ m = self._make_signed() m.attach(email.mime.text.MIMEText('foo')) m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIn('expected exactly two messages, got 3', m[utils.X_SIGNATURE_MESSAGE_HEADER]) # TODO: The case of more than two payloads, or the payloads being out of # order. Also for the encrypted case. def _make_encrypted(self, signed=False): """Create an encrypted (and optionally signed) message.""" if signed: t = self._make_signed() else: text = b'This is some text' t = email.mime.text.MIMEText(text, 'plain', 'utf-8') enc = crypto.encrypt(t.as_bytes(policy=email.policy.SMTP), self.keys) e = email.mime.application.MIMEApplication( enc, 'octet-stream', email.encoders.encode_7or8bit) f = email.mime.application.MIMEApplication( b'Version: 1', 'pgp-encrypted', email.encoders.encode_7or8bit) m = email.mime.multipart.MIMEMultipart('encrypted', None, [f, e]) m.set_param('protocol', 'application/pgp-encrypted') return m def test_encrypted_length(self): # It seems string that we just attach the unsigned message to the end # of the mail, rather than replacing the whole encrypted payload with # it's unencrypted equivalent m = self._make_encrypted() m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertEqual(len(m.get_payload()), 3) def test_encrypted_unsigned_is_decrypted(self): m = self._make_encrypted() m = utils.decrypted_message_from_bytes(m.as_bytes()) # Check using m.walk, since we're not checking for ordering, just # existence. self.assertIn('This is some text', [n.get_payload() for n in m.walk()]) def test_encrypted_unsigned_doesnt_add_signed_headers(self): """Since the message isn't signed, it shouldn't have headers saying that there is a signature. """ m = self._make_encrypted() m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertNotIn(utils.X_SIGNATURE_VALID_HEADER, m) self.assertNotIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) def test_encrypted_signed_is_decrypted(self): m = self._make_encrypted(True) m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIn('This is some text', [n.get_payload() for n in m.walk()]) def test_encrypted_signed_headers(self): """Since the message is signed, it should have headers saying that there is a signature. """ m = self._make_encrypted(True) m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) self.assertIn( 'ambig ', m[utils.X_SIGNATURE_MESSAGE_HEADER]) # TODO: tests for the RFC 2440 style combined signed/encrypted blob def test_encrypted_wrong_mimetype_first_payload(self): m = self._make_encrypted() m.get_payload(0).set_type('text/plain') m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIn('Malformed OpenPGP message:', m.get_payload(2).get_payload()) def test_encrypted_wrong_mimetype_second_payload(self): m = self._make_encrypted() m.get_payload(1).set_type('text/plain') m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIn('Malformed OpenPGP message:', m.get_payload(2).get_payload()) def test_signed_in_multipart_mixed(self): """It is valid to encapsulate a multipart/signed payload inside a multipart/mixed payload, verify that works. """ s = self._make_signed() m = email.mime.multipart.MIMEMultipart('mixed', None, [s]) m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertEqual(m[utils.X_SIGNATURE_VALID_HEADER], 'True') self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) def test_signed_in_multipart_mixed_other_mua(self): """It is valid to encapsulate a multipart/signed payload inside a multipart/mixed payload, verify that works. The signature being sensitive to the way the multipart message was assembled (with blank lines between parts, ...), we need to make sure that we're able to validate messages generated by other MUAs as well. """ mb = Path('tests/static/mail/protonmail-signed.eml').read_bytes() m = utils.decrypted_message_from_bytes(mb) self.assertEqual(m[utils.X_SIGNATURE_VALID_HEADER], 'True') self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) def test_encrypted_unsigned_in_multipart_mixed(self): """It is valid to encapsulate a multipart/encrypted payload inside a multipart/mixed payload, verify that works. """ s = self._make_encrypted() m = email.mime.multipart.MIMEMultipart('mixed', None, [s]) m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIn('This is some text', [n.get_payload() for n in m.walk()]) self.assertNotIn(utils.X_SIGNATURE_VALID_HEADER, m) self.assertNotIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) def test_encrypted_signed_in_multipart_mixed(self): """It is valid to encapsulate a multipart/encrypted payload inside a multipart/mixed payload, verify that works when the multipart/encrypted contains a multipart/signed. """ s = self._make_encrypted(True) m = email.mime.multipart.MIMEMultipart('mixed', None, [s]) m = utils.decrypted_message_from_bytes(m.as_bytes()) self.assertIn('This is some text', [n.get_payload() for n in m.walk()]) self.assertIn(utils.X_SIGNATURE_VALID_HEADER, m) self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) class TestGetBodyPart(unittest.TestCase): def _make_mixed_plain_html(self): mail = EmailMessage() set_basic_headers(mail) mail.set_content('This is an email') mail.add_alternative( 'This is an html email', subtype='html') return mail @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=True)) def test_prefer_plaintext_mixed(self): expected = "text/plain" mail = self._make_mixed_plain_html() actual = utils.get_body_part(mail).get_content_type() self.assertEqual(actual, expected) # Mock the handler to cat, so that no transformations of the html are made # making the result non-deterministic @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=False)) @mock.patch('alot.db.utils.settings.mailcap_find_match', mock.Mock(return_value=(None, {'view': 'cat'}))) def test_prefer_html_mixed(self): expected = 'text/html' mail = self._make_mixed_plain_html() actual = utils.get_body_part(mail).get_content_type() self.assertEqual(actual, expected) def _make_html_only(self): mail = EmailMessage() set_basic_headers(mail) mail.set_content( 'This is an html email', subtype='html') return mail @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=True)) @mock.patch('alot.db.utils.settings.mailcap_find_match', mock.Mock(return_value=(None, {'view': 'cat'}))) def test_prefer_plaintext_only(self): expected = 'text/html' mail = self._make_html_only() actual = utils.get_body_part(mail).get_content_type() self.assertEqual(actual, expected) # Mock the handler to cat, so that no transformations of the html are made # making the result non-deterministic @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=False)) @mock.patch('alot.db.utils.settings.mailcap_find_match', mock.Mock(return_value=(None, {'view': 'cat'}))) def test_prefer_html_only(self): expected = 'text/html' mail = self._make_html_only() actual = utils.get_body_part(mail).get_content_type() self.assertEqual(actual, expected) @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=False)) def test_prefer_html_set_mimetype_plain(self): expected = "text/plain" mail = self._make_mixed_plain_html() actual = utils.get_body_part(mail, 'plain').get_content_type() self.assertEqual(actual, expected) @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=True)) def test_prefer_plaintext_set_mimetype_html(self): expected = 'text/html' mail = self._make_mixed_plain_html() actual = utils.get_body_part(mail, 'html').get_content_type() self.assertEqual(actual, expected) class TestExtractBodyPart(unittest.TestCase): def test_single_text_plain(self): mail = EmailMessage() set_basic_headers(mail) mail.set_content('This is an email') body_part = utils.get_body_part(mail) actual = utils.extract_body_part(body_part) expected = 'This is an email\n' self.assertEqual(actual, expected) @unittest.expectedFailure # This makes no sense def test_two_text_plain(self): mail = email.mime.multipart.MIMEMultipart() set_basic_headers(mail) mail.attach(email.mime.text.MIMEText('This is an email')) mail.attach(email.mime.text.MIMEText('This is a second part')) body_part = utils.get_body_part(mail) actual = utils.extract_body(body_part) expected = 'This is an email\n\nThis is a second part' self.assertEqual(actual, expected) def test_text_plain_with_attachment_text(self): mail = EmailMessage() set_basic_headers(mail) mail.set_content('This is an email') mail.add_attachment('this shouldnt be displayed') body_part = utils.get_body_part(mail) actual = utils.extract_body_part(body_part) expected = 'This is an email\n' self.assertEqual(actual, expected) @mock.patch('alot.db.utils.settings.mailcap_find_match', mock.Mock(return_value=(None, None))) def test_simple_utf8_file(self): with open('tests/static/mail/utf8.eml', 'rb') as f: mail = email.message_from_binary_file( f, _class=email.message.EmailMessage) body_part = utils.get_body_part(mail) actual = utils.extract_body_part(body_part) expected = "Liebe Grüße!\n" self.assertEqual(actual, expected) @mock.patch('alot.db.utils.settings.mailcap_find_match', mock.Mock(return_value=( None, {'view': 'sed "s/!/?/"'}))) def test_utf8_plaintext_mailcap(self): """ Handle unicode correctly in the presence of a text/plain mailcap entry. https://github.com/pazz/alot/issues/1522 """ with open('tests/static/mail/utf8.eml', 'rb') as f: mail = email.message_from_binary_file( f, _class=email.message.EmailMessage) body_part = utils.get_body_part(mail) actual = utils.extract_body_part(body_part) expected = "Liebe Grüße?\n" self.assertEqual(actual, expected) @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=True)) @mock.patch('alot.db.utils.settings.mailcap_find_match', mock.Mock(return_value=( None, {'view': 'sed "s/ is/ was/"'}))) def test_plaintext_mailcap(self): expected = 'This was an email\n' mail = EmailMessage() set_basic_headers(mail) mail.set_content('This is an email') body_part = utils.get_body_part(mail) actual = utils.extract_body_part(body_part) self.assertEqual(actual, expected) @mock.patch('alot.db.utils.settings.mailcap_find_match', mock.Mock(return_value=(None, {'view': 'cat'}))) def test_plaintext_mailcap_wo_content_type(self): with open('tests/static/mail/basic.eml') as fp: mail = email.message_from_file(fp, _class=email.message.EmailMessage) body_part = utils.get_body_part(mail) actual = utils.extract_body_part(body_part) expected = 'test body\n' self.assertEqual(actual, expected) class TestRemoveCte(unittest.TestCase): def test_char_vs_cte_mismatch(self): # #1291 with open('tests/static/mail/broken-utf8.eml') as fp: mail = email.message_from_file(fp) # This should not raise an UnicodeDecodeError. with self.assertLogs(level='DEBUG') as cm: # keep logs utils.remove_cte(mail, as_string=True) # We expect no Exceptions but a complaint in the log logmsg = 'DEBUG:root:Decoding failure: \'utf-8\' codec can\'t decode '\ 'byte 0xa1 in position 14: invalid start byte' self.assertIn(logmsg, cm.output) def test_malformed_cte_value(self): with open('tests/static/mail/malformed-header-CTE.eml') as fp: mail = email.message_from_file(fp) with self.assertLogs(level='INFO') as cm: # keep logs utils.remove_cte(mail, as_string=True) # We expect no Exceptions but a complaint in the log logmsg = 'INFO:root:Unknown Content-Transfer-Encoding: "7bit;"' self.assertEqual(cm.output, [logmsg]) def test_unknown_cte_value(self): with open('tests/static/mail/malformed-header-CTE-2.eml') as fp: mail = email.message_from_file(fp) with self.assertLogs(level='DEBUG') as cm: # keep logs utils.remove_cte(mail, as_string=True) # We expect no Exceptions but a complaint in the log logmsg = 'DEBUG:root:failed to interpret Content-Transfer-Encoding: '\ '"normal"' self.assertIn(logmsg, cm.output) class Test_ensure_unique_address(unittest.TestCase): foo = 'foo ' foo2 = 'foo the fanzy ' bar = 'bar ' baz = 'baz ' def test_unique_lists_are_unchanged(self): expected = sorted([self.foo, self.bar]) actual = utils.ensure_unique_address(expected) self.assertListEqual(actual, expected) def test_equal_entries_are_detected(self): actual = utils.ensure_unique_address( [self.foo, self.bar, self.foo]) expected = sorted([self.foo, self.bar]) self.assertListEqual(actual, expected) def test_same_address_with_different_name_is_detected(self): actual = utils.ensure_unique_address( [self.foo, self.foo2]) expected = [self.foo2] self.assertListEqual(actual, expected) class _AccountTestClass(Account): """Implements stubs for ABC methods.""" def send_mail(self, mail): pass class TestClearMyAddress(unittest.TestCase): me1 = 'me@example.com' me2 = 'ME@example.com' me3 = 'me+label@example.com' me4 = 'ME+label@example.com' me_regex = r'me\+.*@example.com' me_named = 'alot team ' you = 'you@example.com' named = 'somebody you know ' imposter = 'alot team ' mine = _AccountTestClass( address=me1, aliases=[], alias_regexp=me_regex, case_sensitive_username=True) def test_empty_input_returns_empty_list(self): self.assertListEqual( utils.clear_my_address(self.mine, []), []) def test_only_my_emails_result_in_empty_list(self): expected = [] actual = utils.clear_my_address( self.mine, [self.me1, self.me3, self.me_named]) self.assertListEqual(actual, expected) def test_other_emails_are_untouched(self): input_ = [self.you, self.me1, self.me_named, self.named] expected = [self.you, self.named] actual = utils.clear_my_address(self.mine, input_) self.assertListEqual(actual, expected) def test_case_matters(self): input_ = [self.me1, self.me2, self.me3, self.me4] expected = [self.me2, self.me4] actual = utils.clear_my_address(self.mine, input_) self.assertListEqual(actual, expected) def test_same_address_with_different_real_name_is_removed(self): input_ = [self.me_named, self.you] expected = [self.you] actual = utils.clear_my_address(self.mine, input_) self.assertListEqual(actual, expected) class TestFormataddr(unittest.TestCase): address = 'me@example.com' umlauts_and_comma = '"Ö, Ä" ' def test_is_inverse(self): self.assertEqual( utils.formataddr(email.utils.parseaddr(self.umlauts_and_comma)), self.umlauts_and_comma ) def test_address_only(self): self.assertEqual(utils.formataddr(("", self.address)), self.address) def test_name_and_address_no_comma(self): self.assertEqual( utils.formataddr(("Me", self.address)), "Me " ) def test_name_and_address_with_comma(self): self.assertEqual( utils.formataddr(("Last, Name", self.address)), "\"Last, Name\" " ) alot-0.11/tests/settings/000077500000000000000000000000001466311112200153715ustar00rootroot00000000000000alot-0.11/tests/settings/__init__.py000066400000000000000000000000001466311112200174700ustar00rootroot00000000000000alot-0.11/tests/settings/test_manager.py000066400000000000000000000307011466311112200204150ustar00rootroot00000000000000# Copyright (C) 2017 Lucas Hoffmann # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file """Test suite for alot.settings.manager module.""" import os import re import tempfile import textwrap import unittest from unittest import mock from alot.settings.manager import SettingsManager from alot.settings.errors import ConfigError, NoMatchingAccount from .. import utilities class TestSettingsManager(unittest.TestCase): def test_reading_synchronize_flags_from_notmuch_config(self): with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ [maildir] synchronize_flags = true """)) self.addCleanup(os.unlink, f.name) manager = SettingsManager() manager.read_notmuch_config(f.name) actual = manager.get_notmuch_setting('maildir', 'synchronize_flags') self.assertTrue(actual) def test_parsing_notmuch_config_with_non_bool_synchronize_flag_fails(self): with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ [maildir] synchronize_flags = not bool """)) self.addCleanup(os.unlink, f.name) with self.assertRaises(ConfigError): manager = SettingsManager() manager.read_notmuch_config(f.name) def test_reload_notmuch_config(self): with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ [maildir] synchronize_flags = false """)) self.addCleanup(os.unlink, f.name) manager = SettingsManager() manager.read_notmuch_config(f.name) with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ [maildir] synchronize_flags = true """)) self.addCleanup(os.unlink, f.name) manager.read_notmuch_config(f.name) actual = manager.get_notmuch_setting('maildir', 'synchronize_flags') self.assertTrue(actual) def test_read_config_doesnt_exist(self): """If there is not an alot config things don't break. This specifically tests for issue #1094, which is caused by the defaults not being loaded if there isn't an alot config files, and thus calls like `get_theming_attribute` fail with strange exceptions. """ with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ [maildir] synchronize_flags = true """)) self.addCleanup(os.unlink, f.name) manager = SettingsManager() manager.read_config(f.name) manager.get_theming_attribute('global', 'body') def test_unknown_settings_in_config_are_logged(self): # todo: For py3, don't mock the logger, use assertLogs unknown_settings = ['templates_dir', 'unknown_section', 'unknown_1', 'unknown_2'] with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ {x[0]} = /templates/dir [{x[1]}] # Values in unknown sections are not reported. barfoo = barfoo [tags] [[foobar]] {x[2]} = baz translated = translation {x[3]} = bar """.format(x=unknown_settings))) self.addCleanup(os.unlink, f.name) with mock.patch('alot.settings.utils.logging') as mock_logger: manager = SettingsManager() manager.read_config(f.name) success = any(all([s in call_args[0][0] for s in unknown_settings]) for call_args in mock_logger.info.call_args_list) self.assertTrue(success, msg='Could not find all unknown settings in ' 'logging.info.\nUnknown settings:\n{}\nCalls to mocked' ' logging.info:\n{}'.format( unknown_settings, mock_logger.info.call_args_list)) def test_read_notmuch_config_doesnt_exist(self): with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ [accounts] [[default]] realname = That Guy address = thatguy@example.com """)) self.addCleanup(os.unlink, f.name) manager = SettingsManager() manager.read_notmuch_config(f.name) setting = manager.get_notmuch_setting('foo', 'bar') self.assertIsNone(setting) def test_choke_on_invalid_regex_in_tagstring(self): tag = 'to**do' with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ [tags] [[{tag}]] normal = '','', 'white','light red', 'white','#d66' """.format(tag=tag))) self.addCleanup(os.unlink, f.name) manager = SettingsManager() manager.read_config(f.name) with self.assertRaises(re.error): manager.get_tagstring_representation(tag) def test_translate_tagstring_prefix(self): # Test for behavior mentioned in bcb2670f56fa251c0f1624822928d664f6455902, # namely that 'foo' does not match 'foobar' tag = 'foobar' tagprefix = 'foo' with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ [tags] [[{tag}]] translated = matched """.format(tag=tagprefix))) self.addCleanup(os.unlink, f.name) manager = SettingsManager() manager.read_config(f.name) tagrep = manager.get_tagstring_representation(tag) self.assertIs(tagrep['translated'], tag) tagprefixrep = manager.get_tagstring_representation(tagprefix) self.assertEqual(tagprefixrep['translated'], 'matched') def test_translate_tagstring_prefix_regex(self): # Test for behavior mentioned in bcb2670f56fa251c0f1624822928d664f6455902, # namely that 'foo.*' does match 'foobar' tagprefixregexp = 'foo.*' with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ [tags] [[{tag}]] translated = matched """.format(tag=tagprefixregexp))) self.addCleanup(os.unlink, f.name) manager = SettingsManager() manager.read_config(f.name) def matched(t): return manager.get_tagstring_representation(t)['translated'] == 'matched' self.assertTrue(all(matched(t) for t in ['foo', 'foobar', tagprefixregexp])) self.assertFalse(any(matched(t) for t in ['bar', 'barfoobar'])) def test_translate_regexp(self): # Test for behavior mentioned in 108df3df8571aea2164a5d3fc42655ac2bd06c17 # namely that translations themselves can use regex tag = "notmuch::foo" section = "[[notmuch::.*]]" translation = r"'notmuch::(.*)', 'nm:\1'" translated_goal = "nm:foo" with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ [tags] {section} translation = {translation} """.format(section=section, translation=translation))) self.addCleanup(os.unlink, f.name) manager = SettingsManager() manager.read_config(f.name) self.assertEqual(manager.get_tagstring_representation(tag)['translated'], translated_goal) class TestSettingsManagerExpandEnvironment(unittest.TestCase): """ Tests SettingsManager._expand_config_values """ setting_name = 'template_dir' xdg_name = 'XDG_CONFIG_HOME' default = '$%s/alot/templates' % xdg_name xdg_fallback = '~/.config' xdg_custom = '/foo/bar/.config' default_expanded = default.replace('$%s' % xdg_name, xdg_fallback) def test_user_setting_and_env_not_empty(self): user_setting = '/path/to/template/dir' with mock.patch.dict('os.environ', {self.xdg_name: self.xdg_custom}): with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write('template_dir = {}'.format(user_setting)) self.addCleanup(os.unlink, f.name) manager = SettingsManager() manager.read_config(f.name) self.assertEqual(manager._config.get(self.setting_name), os.path.expanduser(user_setting)) def test_configobj_and_env_expansion(self): """ Three expansion styles: %(FOO)s - expanded by ConfigObj (string interpolation) $FOO and ${FOO} - should be expanded with environment variable """ foo_env = 'foo_set_from_env' with mock.patch.dict('os.environ', {self.xdg_name: self.xdg_custom, 'foo': foo_env}): foo_in_config = 'foo_set_in_config' with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(textwrap.dedent("""\ foo = {} template_dir = ${{XDG_CONFIG_HOME}}/$foo/%(foo)s/${{foo}} """.format(foo_in_config))) self.addCleanup(os.unlink, f.name) manager = SettingsManager() manager.read_config(f.name) self.assertEqual(manager._config.get(self.setting_name), os.path.join(self.xdg_custom, foo_env, foo_in_config, foo_env)) class TestSettingsManagerGetAccountByAddress(utilities.TestCaseClassCleanup): """Test the account_matching_address helper.""" @classmethod def setUpClass(cls): config = textwrap.dedent("""\ [accounts] [[default]] realname = That Guy address = that_guy@example.com sendmail_command = /bin/true [[other]] realname = A Dude address = a_dude@example.com sendmail_command = /bin/true """) # Allow settings.reload to work by not deleting the file until the end with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: f.write(config) cls.addClassCleanup(os.unlink, f.name) # Replace the actual settings object with our own using mock, but # ensure it's put back afterwards cls.manager = SettingsManager() cls.manager.read_config(f.name) def test_exists_addr(self): acc = self.manager.account_matching_address('that_guy@example.com') self.assertEqual(acc.realname, 'That Guy') def test_doesnt_exist_return_default(self): acc = self.manager.account_matching_address('doesntexist@example.com', return_default=True) self.assertEqual(acc.realname, 'That Guy') def test_doesnt_exist_raise(self): with self.assertRaises(NoMatchingAccount): self.manager.account_matching_address('doesntexist@example.com') def test_doesnt_exist_no_default(self): with tempfile.NamedTemporaryFile() as f: f.write(b'') settings = SettingsManager() settings.read_config(f.name) with self.assertRaises(NoMatchingAccount): settings.account_matching_address('that_guy@example.com', return_default=True) def test_real_name_will_be_stripped_before_matching(self): acc = self.manager.account_matching_address( 'That Guy ') self.assertEqual(acc.realname, 'A Dude') def test_address_case(self): """Some servers do not differentiate addresses by case. So, for example, "foo@example.com" and "Foo@example.com" would be considered the same. Among servers that do this gmail, yahoo, fastmail, anything running Exchange (i.e., most large corporations), and others. """ acc1 = self.manager.account_matching_address('That_guy@example.com') acc2 = self.manager.account_matching_address('that_guy@example.com') self.assertIs(acc1, acc2) alot-0.11/tests/settings/test_theme.py000066400000000000000000000057311466311112200201120ustar00rootroot00000000000000# Copyright (C) 2017 Lucas Hoffmann # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import unittest from alot.settings import theme DUMMY_THEME = """\ [bufferlist] line = '', '', '', '', '', '' line_even = '', '', '', '', '', '' line_focus = '', '', '', '', '', '' line_odd = '', '', '', '', '', '' [envelope] body = '', '', '', '', '', '' header = '', '', '', '', '', '' header_key = '', '', '', '', '', '' header_value = '', '', '', '', '', '' [global] body = '', '', '', '', '', '' footer = '', '', '', '', '', '' notify_error = '', '', '', '', '', '' notify_normal = '', '', '', '', '', '' prompt = '', '', '', '', '', '' tag = '', '', '', '', '', '' tag_focus = '', '', '', '', '', '' [help] section = '', '', '', '', '', '' text = '', '', '', '', '', '' title = '', '', '', '', '', '' [taglist] line_even = '', '', '', '', '', '' line_focus = '', '', '', '', '', '' line_odd = '', '', '', '', '', '' [namedqueries] line_even = '', '', '', '', '', '' line_focus = '', '', '', '', '', '' line_odd = '', '', '', '', '', '' [search] focus = '', '', '', '', '', '' normal = '', '', '', '', '', '' [[threadline]] focus = '', '', '', '', '', '' normal = '', '', '', '', '', '' [thread] arrow_bars = '', '', '', '', '', '' arrow_heads = '', '', '', '', '', '' attachment = '', '', '', '', '', '' attachment_focus = '', '', '', '', '', '' body = '', '', '', '', '', '' header = '', '', '', '', '', '' header_key = '', '', '', '', '', '' header_value = '', '', '', '', '', '' [[summary]] even = '', '', '', '', '', '' focus = '', '', '', '', '', '' odd = '', '', '', '', '', '' """ class TestThemeGetAttribute(unittest.TestCase): @classmethod def setUpClass(cls): # We use a list of strings instead of a file path to pass in the config # file. This is possible because the argument is handed to # configobj.ConfigObj directly and that accepts eigher: # https://configobj.rtfd.io/en/latest/configobj.html#reading-a-config-file cls.theme = theme.Theme(DUMMY_THEME.splitlines()) def test_invalid_mode_raises_key_error(self): with self.assertRaises(KeyError) as cm: self.theme.get_attribute(0, 'mode does not exist', 'name does not exist') self.assertTupleEqual(cm.exception.args, ('mode does not exist',)) def test_invalid_name_raises_key_error(self): with self.assertRaises(KeyError) as cm: self.theme.get_attribute(0, 'global', 'name does not exist') self.assertTupleEqual(cm.exception.args, ('name does not exist',)) # TODO tests for invalid part arguments. def test_invalid_colorindex_raises_value_error(self): with self.assertRaises(ValueError): self.theme.get_attribute(0, 'global', 'body') alot-0.11/tests/settings/test_utils.py000066400000000000000000000051561466311112200201510ustar00rootroot00000000000000# Copyright (C) 2017 Lucas Hoffmann # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file """Tests for the alot.setting.utils module.""" import unittest from unittest import mock from alot.settings import utils class TestResolveAtt(unittest.TestCase): __patchers = [] fallback = mock.Mock() fallback.foreground = 'some fallback foreground value' fallback.background = 'some fallback background value' @classmethod def setUpClass(cls): cls.__patchers.append(mock.patch( 'alot.settings.utils.AttrSpec', mock.Mock(side_effect=lambda *args: args))) for p in cls.__patchers: p.start() @classmethod def tearDownClass(cls): for p in cls.__patchers: p.stop() @staticmethod def _mock(foreground, background): """Create a mock object that is needed very often.""" m = mock.Mock() m.foreground = foreground m.background = background return m def test_passing_none_returns_fallback(self): actual = utils.resolve_att(None, self.fallback) self.assertEqual(actual, self.fallback) def test_empty_string_in_background_picks_up_background_from_fallback(self): attr = self._mock('valid foreground', '') expected = (attr.foreground, self.fallback.background) actual = utils.resolve_att(attr, self.fallback) self.assertTupleEqual(actual, expected) def test_default_in_background_picks_up_background_from_fallback(self): attr = self._mock('valid foreground', 'default') expected = attr.foreground, self.fallback.background actual = utils.resolve_att(attr, self.fallback) self.assertTupleEqual(actual, expected) def test_empty_string_in_foreground_picks_up_foreground_from_fallback(self): attr = self._mock('', 'valid background') expected = self.fallback.foreground, attr.background actual = utils.resolve_att(attr, self.fallback) self.assertTupleEqual(actual, expected) def test_default_in_foreground_picks_up_foreground_from_fallback(self): attr = self._mock('default', 'valid background') expected = self.fallback.foreground, attr.background actual = utils.resolve_att(attr, self.fallback) self.assertTupleEqual(actual, expected) def test_other_values_are_used(self): attr = self._mock('valid foreground', 'valid background') expected = attr.foreground, attr.background actual = utils.resolve_att(attr, self.fallback) self.assertTupleEqual(actual, expected) alot-0.11/tests/static/000077500000000000000000000000001466311112200150205ustar00rootroot00000000000000alot-0.11/tests/static/gpg-keys/000077500000000000000000000000001466311112200165465ustar00rootroot00000000000000alot-0.11/tests/static/gpg-keys/ambig1-pub.gpg000066400000000000000000000033051466311112200211720ustar00rootroot00000000000000-----BEGIN PGP PUBLIC KEY BLOCK----- mQENBFlmXXwBCACip8btluktWTxaUdyDLpBS2GrXgDe1M/zT0TMaNN8p0r0oUnNf a7dSHb4QLbaY31d3ftt5IoaSXENnJP2WREujQdQ9SlXb4sVNo6W37t0NtKGn9kqp T2ajgXj1lJ+ZiULHRlSmBoA2blFeABE4PRgef+x6aDJpMtODWG/2NaWw/gFn6kqS OGyqMp0nM3OHeEwZAjf+n1f07wqJHK+m1V3I2rY4wm5LST0kZXJGYFDfjaTuTOOC yPMyhWoqJ/CCWavO47MRdYrlM6qUbVBTQ8DSBGZO2yuF/ILLICC8d/ODGva+kNDq Bm4PmYvVrB0osfxMXVBaxezwOKkDiE3w4fOXABEBAAG0GWFtYmlnIDxhbWJpZ0Bl eGFtcGxlLmNvbT6JAU4EEwEIADgCGwMFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AW IQTdGYYoCadXOnQFj/JVk3r7sVYkXQUCXTAbegAKCRBVk3r7sVYkXQZbB/9jM/OE Gtf6XSb00w+1u70q2BFNKKZWYeCH50rIOljeC4rAEp+GBToTpFnxZwtoXZGLh2SD +DVZCeHupJBsC9E7qv3hM2VjyJE14JCAvyj+S2LHhF1Yr9PLApUAQLqvPCtXvCF5 Qr4fC3QxpzWLghPllrW3oTPeaXOu/EXDRWN4ctYqEeluuKsLsZf71BVtKoUAifpI j/yt2qo3fcNC3/wnDdgBn/qpZhm6mj69WN597HRkn3clmxPasa5cOHkaqohVuHTk 2oYDZH8YWbhzjhobfHWPo/2k8SpACTb98JS9tiG8cpeQ+74fG0r1CF0AHPGGyXkV mxKMOBOQ+X7HM5aEuQENBFlmXXwBCADLAS1JN3UykZbL3JCiatPe6Ce6ErkzEwnE CJxTyg2UqsrQJ/SdPRCJ8wyQ0jWBezn/4MNCiJoacPR+YVo6CVi/R/Kc7qfiqxVp 5mfxSf4qpbC1esZ0L20VdhUnWKc+YvSUhGPIe73ruiDVXt+QnyZiLm9BniUNaEL/ j8GI9o5J6y2v3IQJwO9cmVKuQa2aE1c9zG4ZxIrzlgrI8bF2jcm/olx6a1X55Tsq QEA/CEl5tsyr5gOBa/4qXc1STUCZthEKffbDsSH+8d+26Y7Qw201BWWQ6Hx/8jny aKzmP+ANymG2Bj4lioEY96Qu6vzzQ4RwUvkcJB6K1Osr/diwhkeJABEBAAGJATYE GAEIACACGwwWIQTdGYYoCadXOnQFj/JVk3r7sVYkXQUCXTAboQAKCRBVk3r7sVYk Xe3ZCACD1TGV9es49NnyfSBZ3kSJIOFT6uD7akjHTqsDEet+kpufP7jVsL3mZ3MY 8WV4VC9KW+VgPgWGMLDIiTNrQ240XFXhiGs+W45Nf9EdS1o7f9yQwskIERd0ZNJq kJ4AoHyz4zVS0+krSqrMWHUkjrbVwS80/kc08yTrBos+spDeDsSn6x9Ebrs5n4gW 6xeKHvJXAJWU/wsgU6t47BWD6aX4pbh3SG/umrWHJ6oiM2zPMvP4D5vxXyHj3guf 0yqm4SG125KECJt/Jy/YdIwu7ksppLxY5mD517iBwEZnd8QY/jRa/V2rLJltL8AT wRUHSm/4vOCLTuZsDm0ZmWcdNNYO =CY00 -----END PGP PUBLIC KEY BLOCK----- alot-0.11/tests/static/gpg-keys/ambig1-sec.gpg000066400000000000000000000066521466311112200211660ustar00rootroot00000000000000-----BEGIN PGP PRIVATE KEY BLOCK----- lQOYBFlmXXwBCACip8btluktWTxaUdyDLpBS2GrXgDe1M/zT0TMaNN8p0r0oUnNf a7dSHb4QLbaY31d3ftt5IoaSXENnJP2WREujQdQ9SlXb4sVNo6W37t0NtKGn9kqp T2ajgXj1lJ+ZiULHRlSmBoA2blFeABE4PRgef+x6aDJpMtODWG/2NaWw/gFn6kqS OGyqMp0nM3OHeEwZAjf+n1f07wqJHK+m1V3I2rY4wm5LST0kZXJGYFDfjaTuTOOC yPMyhWoqJ/CCWavO47MRdYrlM6qUbVBTQ8DSBGZO2yuF/ILLICC8d/ODGva+kNDq Bm4PmYvVrB0osfxMXVBaxezwOKkDiE3w4fOXABEBAAEAB/4mXhMjihx4sPr2hybP 3tT2ZcxWBw2c9aVmxYsbXGtjry0lbMWANaVpflCN+mp/BvfX3RmiKk26Cn9vvh7/ Kh75ZJbO2lEEbCqEVNzLVVHZYMldGFCmPW+FlA3XR/aZvfH9lY50F0Z5EG6rELL/ JBIjZ6N9gESb4fxYmCzY0/DAndmYrvnLDvG+fRm96MFFW2hBTjHH0QYQllZYJfwz TKhiyC4neyU+nI/+erPR7FXkelIp/I++8SKQbCZoCBBojJKRhxzMI7ERLPLPumTQ FYKhFm8WiYgGEeZ3MOC8IBFAsACtCJcqjAGF3bL8v60HyiuGr0xllriaOCB8eEB8 sboJBADEF1wx05gkYZa0HeE88dClCpgevlK1RQUPKsQAXG7gt7Eiy/AYDPm5oJkD h1G3bg8p/DD0EwlmEylmfchpV1PVsBLDzWb8krDhJQN3wGsiiHHljckd4L7Gu7Wf rFgENBsvgrdQFvN7tsCXfXVOPW1BAHyxAmRpXnWZ271AM7XLDQQA1FlWwxRceQMo w+isXM2VRwevBQgD8HnBoGFWm+TsZboQeNocuojM5UA49iFFe2geG3070U4/3aTr hoshLC15VwmspXy3g3YQlvuB0NlJaaqlQy9Q+MUeyrKbxWweUn12SqtcG6yV+/hy zHX6VcaAinar0/l9lHHnthHWy520gDMEAJC8NI6kgQIfLCLGTzmDeOmTpcvRZrFV Q7l0AnWvTK5KQHdkrbjz4HjN0yhmmwgquFi9ZAjSfjuvetggQ1d3/X50XyEBM25K h4XNoWaTPdoh9PkUkfLipj3b703dzAgI5tFlXQuYgPfi5mj/P+tNCOITDz92Z3H3 i+RITGJOL9/DO2e0GWFtYmlnIDxhbWJpZ0BleGFtcGxlLmNvbT6JAU4EEwEIADgC GwMFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AWIQTdGYYoCadXOnQFj/JVk3r7sVYk XQUCXTAbegAKCRBVk3r7sVYkXQZbB/9jM/OEGtf6XSb00w+1u70q2BFNKKZWYeCH 50rIOljeC4rAEp+GBToTpFnxZwtoXZGLh2SD+DVZCeHupJBsC9E7qv3hM2VjyJE1 4JCAvyj+S2LHhF1Yr9PLApUAQLqvPCtXvCF5Qr4fC3QxpzWLghPllrW3oTPeaXOu /EXDRWN4ctYqEeluuKsLsZf71BVtKoUAifpIj/yt2qo3fcNC3/wnDdgBn/qpZhm6 mj69WN597HRkn3clmxPasa5cOHkaqohVuHTk2oYDZH8YWbhzjhobfHWPo/2k8SpA CTb98JS9tiG8cpeQ+74fG0r1CF0AHPGGyXkVmxKMOBOQ+X7HM5aEnQOYBFlmXXwB CADLAS1JN3UykZbL3JCiatPe6Ce6ErkzEwnECJxTyg2UqsrQJ/SdPRCJ8wyQ0jWB ezn/4MNCiJoacPR+YVo6CVi/R/Kc7qfiqxVp5mfxSf4qpbC1esZ0L20VdhUnWKc+ YvSUhGPIe73ruiDVXt+QnyZiLm9BniUNaEL/j8GI9o5J6y2v3IQJwO9cmVKuQa2a E1c9zG4ZxIrzlgrI8bF2jcm/olx6a1X55TsqQEA/CEl5tsyr5gOBa/4qXc1STUCZ thEKffbDsSH+8d+26Y7Qw201BWWQ6Hx/8jnyaKzmP+ANymG2Bj4lioEY96Qu6vzz Q4RwUvkcJB6K1Osr/diwhkeJABEBAAEAB/kBPUh7fz8QSBdEqBCqdpj5fS8+FVjT 4Idof87XWUtkozPzbszl/gkYozgP1Rx6/Jl+Z33zGmSElQtBj3KY/BxyesCtgCFk JmldStvht3qE8zIEZ77mMcCi+fccgkSF83R6G6Y5P7ZFtvuqr/DFt7xwX5pR0NOX 6InBHmJFogf1iXG5aOgyHvxL68QJetvJu58K4RHg2uWyoRt0b6xwL06/bqH9NM0I TjlOf+qKyf6/69B2PnKVKrkobzP8t3nh4te00xHSLlxFFWGHXGACDa/QKkVdeDCF PJSjh4lqKVl4DAAUxmf3rxkRvg1bU+8kX+xDhw1MLIZ2VmH4SgGDkOZJBADg94tS FyXekWEhEdNypeHdcb//sLRv9L7Z2tQ+xKksymBvIMEXSEWUTO6nFF5OgtURsn2t m1qX8u52wok4avbndtQjEpJ/EqXuIFdJ0vsCPnZPEzcRy9fhii0EDKuArzpM1Wat s+M45X+TzxHmEeBXF/z30qvMoHdOuRLiQIdXKwQA5wIOYVA9r92fCs08ar/hfhGH Kray8P7aOTN4pSR4E5CjjIDArHw0aVsk0oY6MgqxnMC/f57HH7LgM8MRetqBUuNo JzI+3YoQdqVi4Cdgs/8WDMvibOCPxiiXVsVLS0gYUTN8sJpLy9dLxzr3f+m9GFHG AjviRbJGOxGbTOXVQhsD/0l9vKvlS76/Rm3GCeo5kaqDVjt/Zh4YwLwyOcnQFafO VS2vpNH1A3LCBxUFqXjpyrnXhCDfblbwsPe2y8T28jFWUInvzNqOjKHiawYD1mSy wF/YR67cO51ZrbL/6lJv0TiGvN5n5PKBWrXoYcRDwUdM2Qq238b4TqGq+ZlnbXV4 SU2JATYEGAEIACACGwwWIQTdGYYoCadXOnQFj/JVk3r7sVYkXQUCXTAboQAKCRBV k3r7sVYkXe3ZCACD1TGV9es49NnyfSBZ3kSJIOFT6uD7akjHTqsDEet+kpufP7jV sL3mZ3MY8WV4VC9KW+VgPgWGMLDIiTNrQ240XFXhiGs+W45Nf9EdS1o7f9yQwskI ERd0ZNJqkJ4AoHyz4zVS0+krSqrMWHUkjrbVwS80/kc08yTrBos+spDeDsSn6x9E brs5n4gW6xeKHvJXAJWU/wsgU6t47BWD6aX4pbh3SG/umrWHJ6oiM2zPMvP4D5vx XyHj3guf0yqm4SG125KECJt/Jy/YdIwu7ksppLxY5mD517iBwEZnd8QY/jRa/V2r LJltL8ATwRUHSm/4vOCLTuZsDm0ZmWcdNNYO =eVf4 -----END PGP PRIVATE KEY BLOCK----- alot-0.11/tests/static/gpg-keys/ambig2-pub.gpg000066400000000000000000000023071466311112200211740ustar00rootroot00000000000000 Yf^V*>1b@} ZmJY/ۧ\#< yel_Vz܏`Kűk[|:d>|opG7@5 ݍZ!N>t;:'YV8s{A sg/`ݖtJluTC/<݆K1-dڎBdlt`,+Xe4cW`}҈5Dk-q`Ay @7Aambigu N8  ! qNFoM#<0ߒVl]0 0ߒVl nblȈ8} xkhW@0,&*=zd~DF ^$J+z,ZuIlk4nۃBW|#v]qLI9|SJ.E_>cEh$H*'%\떱:olt\_\K<2E]%KτH4~vM c TqܹβѼi}bD9Wy8~ƕ?l Yf^I⧪+5'=AͨB~JJ"eiB[ tQu~7]1b@} ZmJY/ۧ\#< yel_Vz܏`Kűk[|:d>|opG7@5 ݍZ!N>t;:'YV8s{A sg/`ݖtJluTC/<݆K1-dڎBdlt`,+Xe4cW`}҈5Dk-q`Ay @7A%Q验#B_@}O}UPV37ܙ+|ӜuWRI#bB߫ô!E=A/rJ)Ga?N=VmßN!jxoBi?njf%XP"WՅ`9@e5\n(2jO=@/Ҽ Ttrv9;'hĞ| }A{ωGJm|ī$;Zo!OԽ,փRU셹5!嶚J߼W<#.KGak2'\q@> fo.%Ep$!;dLS2`ElÓmQqj*L IkIoWO! L4ambigu N8  ! qNFoM#<0ߒVl]0 0ߒVl nblȈ8} xkhW@0,&*=zd~DF ^$J+z,ZuIlk4nۃBW|#v]qLI9|SJ.E_>cEh$H*'%\떱:olt\_\K<2E]%KτH4~vM c TqܹβѼi}bD9Wy8~ƕ?lYf^I⧪+5'=AͨB~JJ"eiB[ tQu~7]_Z aQ>= l5.柫5g]>`SdGg2:!t)'&?eF?_ی`̭P:o_vW%)L7̧r=in G(cg {'Ǭ 8X~e,fyMiQ̇.+3LB->gDG -+L yx"zvqE'ar!Ћ(7fd{3y,ymʀ_0`v4eztq 0_=gG*j|;i%bO} TLO#:Hw!<귩E&z?3‡T0Y-st\#`B49wq_:S6  ! qNFoM#<0ߒVl]0 0ߒVl6| j^~X?Ba/$,1O/ejNđߏA8ǬM2}٧O)FyD-tWhR8łe1XOq8{)Yhss;@QTFNɏ(!1f*밂BA[ %0ڐ I.lvLGȟkHS4Q+l{ ae+I7bcω;nOp`[t4mHB {#pcaalot-0.11/tests/static/gpg-keys/pub.gpg000066400000000000000000000033111466311112200200310ustar00rootroot00000000000000-----BEGIN PGP PUBLIC KEY BLOCK----- mQENBFllJaEBCACjvX5b8EkZI9b8uJnhjVwF3GKd/EJWnGYSLm5yQ1K7EKlAW8VK yXM63iIqNbWTOzp52RtuNXFilXOabH3yZstBF8yn65uGm66CIdCJ8hOYZNhXVQGv 27aZ7dwwBxZNd4WCrskvD7Ll1DZZR+j1bMJSDLIh1i3OhR0uKUpTPT1Cp/XmTfII wL3oh57PyNqlS6WXwkAVO8ZrRJQJP76ReSfvLKPaRwvPyuH0HD2A8U6gzbh+qVZl UbDOwkka7C8yJV29805dT03bNPP0FKvo53xlzHVquO58AlCnhx3xT2+mg2TEwp4N /Kz5E3PIm2njZLUS029vw83NNu04nRomzcO7ABEBAAG0HHRlc3QgdXNlciA8dGVz dEBleGFtcGxlLmNvbT6JAU4EEwEIADgCGwMFCwkIBwIGFQgJCgsCBBYCAwECHgEC F4AWIQT3QJHUEz+H1WtdNDwZdOxV+8LWYAUCXTAb1AAKCRAZdOxV+8LWYPloB/0T NbC7wLiSyQJWSgwkQYH125G6pbMGY1u8JzoaBjnk5mwCvckvsxy6Z8Ubaav/dlMp rJcicMAvWKL6vpdTl4MaDi4cY+9jCe8CGzMbHx0PsvPIKIKsYTUDeFPQxDe0nrnl se5EzUFXL5TLtddfLl5QeGeVh9Q8egCuCCqlKAjyncO0tmLZNLf8ZcJoyiTV6Re6 j7Xr5vvYnAEUEIVoUcH9zlxXnmbuZBqS82pzUWkJWeh+Aeh2BdZsCQ+FgRmvgNGo Ilre0VenEhSJg/7kBZc1AgRCFKHwTh++RHtU74aP0SzglISjCdMwtdKLMmEhFRHZ av01bAUP0bwcfRb/g2CwuQENBFllJaEBCADPqqyFwZVgvHPwG1GOPM5Opy2zb4hs gryOzNhddzVHzGD5pKyJsJiHrS+wOkIXMxvCfx0d1DL4vkwwsRMJND+07T2fFCQX YqsGq36vyf/XgofUYrZVTQaBqRJ1IubD6n1kM+OljPLXyT2BhRuJbP40LFTBminn drMg55AkI4Sh8t/TIgwZtaxmzYrb94CNoK8Q/pEzFhMVe+0XlbyXn1x1fxnnmuHU eZP8lC9jdVIFBxYnGK3vcG0xmtHjE5wYiQ38+/IS+nn/zrs6OWpXF+L6UQE/53N0 dG+tSWQ3kDv6HyAZCJRR2npkN4n0Ngq3Wx0fRgg0BnyZV2DsNeu1Mj3FABEBAAGJ ATYEGAEIACACGwwWIQT3QJHUEz+H1WtdNDwZdOxV+8LWYAUCXTAb2wAKCRAZdOxV +8LWYNPvB/4/laOoi36KUkBupd0VFUpir5WDITnHxDyiNhPGr/YYEihOES1Dhmwt scELxmdhRNISrqf32xYYs9r32i+FiMlWJG3piCF73t9q3KIO1KB+knHRt3s5MUH/ qC0cL9BocDy+6f9Mu7UlVc1ez+zBv/nnQa9V8BWysFiQmUnpZLo4eAn6fx+ovaB3 uR0e6j7gj+IW8Hu9QBbANYI6LTLVVtRFeVm+UO38E23bOHuBjjXYamDj4TswRJJf 9DgYX9rsdU0gtCuVWsAVMF3acnAUkvz02nvpSC3EL9kX5QGezwv+IcntCNLZmaC4 qcaF/tTP9obCKwxXJFmj0b2PaQEtZ/1y =q1xQ -----END PGP PUBLIC KEY BLOCK----- alot-0.11/tests/static/gpg-keys/sec.gpg000066400000000000000000000066561466311112200200340ustar00rootroot00000000000000-----BEGIN PGP PRIVATE KEY BLOCK----- lQOYBFllJaEBCACjvX5b8EkZI9b8uJnhjVwF3GKd/EJWnGYSLm5yQ1K7EKlAW8VK yXM63iIqNbWTOzp52RtuNXFilXOabH3yZstBF8yn65uGm66CIdCJ8hOYZNhXVQGv 27aZ7dwwBxZNd4WCrskvD7Ll1DZZR+j1bMJSDLIh1i3OhR0uKUpTPT1Cp/XmTfII wL3oh57PyNqlS6WXwkAVO8ZrRJQJP76ReSfvLKPaRwvPyuH0HD2A8U6gzbh+qVZl UbDOwkka7C8yJV29805dT03bNPP0FKvo53xlzHVquO58AlCnhx3xT2+mg2TEwp4N /Kz5E3PIm2njZLUS029vw83NNu04nRomzcO7ABEBAAEAB/sEyUqsAC+L+EvxXYUy AvSs0XgYgiZa2TSqnj8gQIFCTgapGPNcS26jJiu0WeBtLsXA0X7+WExweiVWFfil F+FIjYnUhsLfFCZ/vZlNhdZIzLC1GokFzYqne4XqMqGxsXBugu4HJwBTc4so4GGH xDCtY5tU1Lvs6Bal/VwjsQwBqnc/1JyI+/8TkIf6BssYvlWMVvDm6SeLMos4AwPf JOg+mvqKOktsjzpfAhlNVzEn1rPnOBgeDVdh5KDDPBIY8FjzmKV6kAechGqkgtBY 4Lmhn+CBwy9rvIrVroQLVd9dMH0rJG7v8HCMck+h198K6DJfwkAlKDu5cL3nXhIC A+zxBADFokxgH+Me1nKjsddtae6eOQaSNc/GihimPvsLvit9Dr0VPaiTwiNIbLpN tDZx3esNEWThMDJoXVhKb6CetwirJuqSH9ZbV6oGEq3S9eFsBSdMRNl6/XB65mIT uJdruZKmJjREn2qRCHuqralRB6zqTDMFBDnT5LBHtgjRR1f1ZwQA1Bi6ZBIbHzmJ YMsaZZ6YF9mvaPoQdVodmAAvo3SgVWEhoWNZ8YF7wjxfG6rpkS3PRTCjHpg8EfxG Zjue2U8RmgwmLnecUT4qQQcyWWQzM5wLFcMkIeTl8lJa/MOEEqLGgeKllzJApKkB E6atGeZRt51/yMRL0K3HwWfHrsPdVo0D/3mVvs8Jlq/IHPVLfUSOl9xmE+LALeL1 hMZx6TBjYYk528lBV+JLS7w5twqvyN/U7PxadX5bwrVJoovssyOh9IpkHcBzL6CY hT6vEZXiLjYPYeRuYBGW1kRkQmlPpNsNDJTFQ9Mve0QYldPLgFEbzZgnid2siWDn uRe34gzXP5drNti0HHRlc3QgdXNlciA8dGVzdEBleGFtcGxlLmNvbT6JAU4EEwEI ADgCGwMFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AWIQT3QJHUEz+H1WtdNDwZdOxV +8LWYAUCXTAb1AAKCRAZdOxV+8LWYPloB/0TNbC7wLiSyQJWSgwkQYH125G6pbMG Y1u8JzoaBjnk5mwCvckvsxy6Z8Ubaav/dlMprJcicMAvWKL6vpdTl4MaDi4cY+9j Ce8CGzMbHx0PsvPIKIKsYTUDeFPQxDe0nrnlse5EzUFXL5TLtddfLl5QeGeVh9Q8 egCuCCqlKAjyncO0tmLZNLf8ZcJoyiTV6Re6j7Xr5vvYnAEUEIVoUcH9zlxXnmbu ZBqS82pzUWkJWeh+Aeh2BdZsCQ+FgRmvgNGoIlre0VenEhSJg/7kBZc1AgRCFKHw Th++RHtU74aP0SzglISjCdMwtdKLMmEhFRHZav01bAUP0bwcfRb/g2CwnQOYBFll JaEBCADPqqyFwZVgvHPwG1GOPM5Opy2zb4hsgryOzNhddzVHzGD5pKyJsJiHrS+w OkIXMxvCfx0d1DL4vkwwsRMJND+07T2fFCQXYqsGq36vyf/XgofUYrZVTQaBqRJ1 IubD6n1kM+OljPLXyT2BhRuJbP40LFTBminndrMg55AkI4Sh8t/TIgwZtaxmzYrb 94CNoK8Q/pEzFhMVe+0XlbyXn1x1fxnnmuHUeZP8lC9jdVIFBxYnGK3vcG0xmtHj E5wYiQ38+/IS+nn/zrs6OWpXF+L6UQE/53N0dG+tSWQ3kDv6HyAZCJRR2npkN4n0 Ngq3Wx0fRgg0BnyZV2DsNeu1Mj3FABEBAAEAB/oD+AKREdiNfzyF/7eozL+yoB5O +hg03rDE1+RgsOkLRLwruTp07TOVEDnDl/FwaREkP/KqAcaxm01wdsni2KVJC2ms kyF3cvLKz1c9+9HQaBW5eON8MNspejY4l+CqKN6ZniZBITb46ccrpQQYNcWL8Lbz 9kLLwih9Pf+yuR0NTUKpmdii3WANE0bolNTXEe0M2df8f5uG5a8tQEHLFXTNFmWx JNIjZkWPc9ezczmJ3JtvEoHcLPTz1SCqAIcp1/ZC0YWVQWRVomeXfhTSFsps1/2q hItIzWyAcZntwlNbjOYHMSVgo1OVrKsMLgGk6XKM2+IpvG6ABx+z7AaUUW6hBADf osc/Fn1ZoeE+6B7ceHhtjAqsReqSkuS/1U5cN5BKmx0xfrLbOUqdPeBrVpdsmIfF V+rD67d35APrFELGvojOxo/6/kBfOdjMYjKhlzUGuYTen4qjkCxf/27d1ji8nPl/ VRAynAfZ4HdhNaTpM81JIgq5LnWZRwKvteeOj+pzEQQA7bhF54pR/drT+jmYyOSp gkE3/WIqKxLY+cP6hC0dTGb2i5uXvp2NZppxTqt9UtuN3oSWra6HXf6TbXmlxnUO opdzI5TiqGk4aoWE7obwwY12FaVTwdHyJ561JbtS+iBJygi2WV/Vpbn2/ijxBsFd FqkJOnS8RdixViV/04UjN3UD/REGfH9krzvGknuWHo5T6q1JXaNwfKDmXTmtakDl zidMs1jDr9S/HeujPBJvtuIqWRZHn0TYCEnb7ZwSZpTOmcwH12WFKcj6HUHQR5Mr fXhWBk5nvMyVFiLueylBmxfaIRvGgkYELWXtGjvlSn9K0Mb48sYZAQXUBhtgSu9i cPdVSwiJATYEGAEIACACGwwWIQT3QJHUEz+H1WtdNDwZdOxV+8LWYAUCXTAb2wAK CRAZdOxV+8LWYNPvB/4/laOoi36KUkBupd0VFUpir5WDITnHxDyiNhPGr/YYEihO ES1DhmwtscELxmdhRNISrqf32xYYs9r32i+FiMlWJG3piCF73t9q3KIO1KB+knHR t3s5MUH/qC0cL9BocDy+6f9Mu7UlVc1ez+zBv/nnQa9V8BWysFiQmUnpZLo4eAn6 fx+ovaB3uR0e6j7gj+IW8Hu9QBbANYI6LTLVVtRFeVm+UO38E23bOHuBjjXYamDj 4TswRJJf9DgYX9rsdU0gtCuVWsAVMF3acnAUkvz02nvpSC3EL9kX5QGezwv+Icnt CNLZmaC4qcaF/tTP9obCKwxXJFmj0b2PaQEtZ/1y =wBTO -----END PGP PRIVATE KEY BLOCK----- alot-0.11/tests/static/mail/000077500000000000000000000000001466311112200157425ustar00rootroot00000000000000alot-0.11/tests/static/mail/basic.eml000066400000000000000000000001061466311112200175170ustar00rootroot00000000000000From: me@localhost To: you@localhost Subject: test subject test body alot-0.11/tests/static/mail/broken-utf8.eml000066400000000000000000000003301466311112200206010ustar00rootroot00000000000000Subject: Broken UTF8 byte in quoted printable From: josh@github To: test@alot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: quoted-printable =C2=A1This works! =A1This doesn't! alot-0.11/tests/static/mail/malformed-header-CTE-2.eml000066400000000000000000000004231466311112200224040ustar00rootroot00000000000000Subject: malformed Content-Transfer-Encoding v2 To: lucc@github From: test@alot MIME-Version: 1.0; Content-Type: text/plain; charset=iso-8859-1; Content-Transfer-Encoding: normal This message contains an invalid header value for the "Content-Transfer-Encoding": issue #1301 alot-0.11/tests/static/mail/malformed-header-CTE.eml000066400000000000000000000004621466311112200222500ustar00rootroot00000000000000Subject: malformed Content-Transfer-Encoding To: lucc@github From: test@alot MIME-Version: 1.0; Content-Type: text/plain; charset=iso-8859-1; Content-Transfer-Encoding: 7bit; This message contains a malformed header value for the "Content-Transfer-Encoding": It should be "7bit", not "7bit;". issue #1301 alot-0.11/tests/static/mail/protonmail-signed.eml000066400000000000000000000027161466311112200221020ustar00rootroot00000000000000To: test From: test Subject: test MIME-Version: 1.0 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg=pgp-sha256; boundary="---------------------b1a1d581e2f9973dec483e5b8dea8467"; charset=UTF-8 This is an OpenPGP/MIME signed message (RFC 4880 and 3156) -----------------------b1a1d581e2f9973dec483e5b8dea8467 Content-Type: multipart/mixed;boundary=---------------------103eabeea9de680baf0de1110c9de38f -----------------------103eabeea9de680baf0de1110c9de38f Content-Transfer-Encoding: quoted-printable Content-Type: text/plain;charset=utf-8 This is a signed multipart message sent from ProtonMail. -----------------------103eabeea9de680baf0de1110c9de38f-- -----------------------b1a1d581e2f9973dec483e5b8dea8467 Content-Type: application/pgp-signature; name="signature.asc" Content-Description: OpenPGP digital signature Content-Disposition: attachment; filename="signature.asc" -----BEGIN PGP SIGNATURE----- Version: ProtonMail wsBcBAEBCAAGBQJejPBTAAoJEBl07FX7wtZg2XYH/3E0TLJ44OA36J55tv+i FAnXy0ncTsqSJk2RloMI+rgJW6Ky1nkLMdPFuT7imBkuMGEFOv5NtWyuF6R+ ZTxML7JNEzK1kj/egvWMWgjAoKQ/OXhGEANwmeV90LseOlbPp6sppxgHcGVv LymaQHPs4M5IDScum0jnywQ017esxQ4CuINuQsv3Tbp9E/nl5PrmwXsqB1ov Z38n7RkJD523sPFuk6E6XrDaiod8J/Jd+0y7iWAVuxRyOVBnbHmMwkU8wvpI VWkTLGNB2azregJp678DL7vfp5483tn4PqgSH5RO86glgo87uEin0cTmvdhp +UQoeT6rE/dza6g2dGYG+co= =h6OL -----END PGP SIGNATURE----- -----------------------b1a1d581e2f9973dec483e5b8dea8467-- alot-0.11/tests/static/mail/sender-with-wide-chars.eml000066400000000000000000000001611466311112200227140ustar00rootroot00000000000000Subject: wide chars in From: header From: =?utf-8?b?5ZC06bmP?= To: test@alot testing issue #1262. alot-0.11/tests/static/mail/utf8.eml000066400000000000000000000002561466311112200173320ustar00rootroot00000000000000From: lucc@github To: tests@alot Subject: plain utf8 8bit message MIME-Version: 1.0 Content-Type: text/plain; charset="UTF-8" Content-Transfer-Encoding: 8bit Liebe Grüße! alot-0.11/tests/test_account.py000066400000000000000000000157531466311112200166110ustar00rootroot00000000000000# encoding=utf-8 # Copyright © 2017 Dylan Baker # # 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 . import logging import unittest from alot import account from . import utilities class _AccountTestClass(account.Account): """Implements stubs for ABC methods.""" def send_mail(self, mail): pass class TestAccount(unittest.TestCase): """Tests for the Account class.""" def test_matches_address(self): """Tests address without aliases.""" acct = _AccountTestClass(address="foo@example.com") self.assertTrue(acct.matches_address("foo@example.com")) self.assertFalse(acct.matches_address("bar@example.com")) def test_matches_address_with_aliases(self): """Tests address with aliases.""" acct = _AccountTestClass(address="foo@example.com", aliases=['bar@example.com']) self.assertTrue(acct.matches_address("foo@example.com")) self.assertTrue(acct.matches_address("bar@example.com")) self.assertFalse(acct.matches_address("baz@example.com")) def test_matches_address_with_regex_aliases(self): """Tests address with regex aliases.""" acct = _AccountTestClass(address="foo@example.com", alias_regexp=r'to\+.*@example.com') self.assertTrue(acct.matches_address("to+foo@example.com")) self.assertFalse(acct.matches_address("to@example.com")) def test_deprecated_encrypt_by_default(self): """Tests that deprecated values are still accepted.""" for each in ['true', 'yes', '1']: acct = _AccountTestClass(address='foo@example.com', encrypt_by_default=each) self.assertEqual(acct.encrypt_by_default, 'all') for each in ['false', 'no', '0']: acct = _AccountTestClass(address='foo@example.com', encrypt_by_default=each) self.assertEqual(acct.encrypt_by_default, 'none') class TestAddress(unittest.TestCase): """Tests for the Address class.""" def test_from_string(self): addr = account.Address.from_string('user@example.com') self.assertEqual(addr.username, 'user') self.assertEqual(addr.domainname, 'example.com') def test_str(self): addr = account.Address('ušer', 'example.com') self.assertEqual(str(addr), 'ušer@example.com') def test_eq_unicode(self): addr = account.Address('ušer', 'example.com') self.assertEqual(addr, 'ušer@example.com') def test_eq_address(self): addr = account.Address('ušer', 'example.com') addr2 = account.Address('ušer', 'example.com') self.assertEqual(addr, addr2) def test_ne_unicode(self): addr = account.Address('ušer', 'example.com') self.assertNotEqual(addr, 'user@example.com') def test_ne_address(self): addr = account.Address('ušer', 'example.com') addr2 = account.Address('user', 'example.com') self.assertNotEqual(addr, addr2) def test_eq_unicode_case(self): addr = account.Address('UŠer', 'example.com') self.assertEqual(addr, 'ušer@example.com') def test_ne_unicode_case(self): addr = account.Address('ušer', 'example.com') self.assertEqual(addr, 'uŠer@example.com') def test_ne_address_case(self): addr = account.Address('ušer', 'example.com') addr2 = account.Address('uŠer', 'example.com') self.assertEqual(addr, addr2) def test_eq_address_case(self): addr = account.Address('UŠer', 'example.com') addr2 = account.Address('ušer', 'example.com') self.assertEqual(addr, addr2) def test_eq_unicode_case_sensitive(self): addr = account.Address('UŠer', 'example.com', case_sensitive=True) self.assertNotEqual(addr, 'ušer@example.com') def test_eq_address_case_sensitive(self): addr = account.Address('UŠer', 'example.com', case_sensitive=True) addr2 = account.Address('ušer', 'example.com') self.assertNotEqual(addr, addr2) def test_eq_str(self): addr = account.Address('user', 'example.com', case_sensitive=True) with self.assertRaises(TypeError): addr == 1 # pylint: disable=pointless-statement def test_ne_str(self): addr = account.Address('user', 'example.com', case_sensitive=True) with self.assertRaises(TypeError): addr != 1 # pylint: disable=pointless-statement def test_repr(self): addr = account.Address('user', 'example.com', case_sensitive=True) self.assertEqual( repr(addr), "Address('user', 'example.com', case_sensitive=True)") def test_domain_name_ne(self): addr = account.Address('user', 'example.com') self.assertNotEqual(addr, 'user@example.org') def test_domain_name_eq_case(self): addr = account.Address('user', 'example.com') self.assertEqual(addr, 'user@Example.com') def test_domain_name_ne_unicode(self): addr = account.Address('user', 'éxample.com') self.assertNotEqual(addr, 'user@example.com') def test_domain_name_eq_unicode(self): addr = account.Address('user', 'éxample.com') self.assertEqual(addr, 'user@Éxample.com') def test_domain_name_eq_case_sensitive(self): addr = account.Address('user', 'example.com', case_sensitive=True) self.assertEqual(addr, 'user@Example.com') def test_domain_name_eq_unicode_sensitive(self): addr = account.Address('user', 'éxample.com', case_sensitive=True) self.assertEqual(addr, 'user@Éxample.com') def test_cmp_empty(self): addr = account.Address('user', 'éxample.com') self.assertNotEqual(addr, '') class TestSend(unittest.TestCase): @utilities.async_test async def test_logs_on_success(self): a = account.SendmailAccount(address="test@alot.dev", cmd="true") with self.assertLogs() as cm: await a.send_mail("some text") #self.assertIn(cm.output, "sent mail successfullya") self.assertIn("INFO:root:sent mail successfully", cm.output) @utilities.async_test async def test_failing_sendmail_command_is_noticed(self): a = account.SendmailAccount(address="test@alot.dev", cmd="false") with self.assertRaises(account.SendingMailFailed): with self.assertLogs(level=logging.ERROR): await a.send_mail("some text") alot-0.11/tests/test_completion.py000066400000000000000000000100611466311112200173110ustar00rootroot00000000000000# encoding=utf-8 # Copyright (C) 2017 Lucas Hoffmann # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file """Tests for the alot.completion module.""" import unittest from unittest import mock from alot.completion.abooks import AbooksCompleter from alot.completion.stringlist import StringlistCompleter # Good descriptive test names often don't fit PEP8, which is meant to cover # functions meant to be called by humans. # pylint: disable=invalid-name def _mock_lookup(query): """Look up the query from fixed list of names and email addresses.""" abook = [ ("", "no-real-name@example.com"), ("foo", "foo@example.com"), ("Ümläut", "umlaut@example.com"), ("comma, person", "comma@example.com"), ("single 'quote' person", "squote@example.com"), ('double "quote" person', "dquote@example.com"), ("""all 'fanzy' "stuff" at, once""", "all@example.com") ] results = [] for name, email in abook: if query in name or query in email: results.append((name, email)) return results class AbooksCompleterTest(unittest.TestCase): """Tests for the address book completion class.""" @classmethod def setUpClass(cls): abook = mock.Mock() abook.lookup = _mock_lookup cls.empty_abook_completer = AbooksCompleter([]) cls.example_abook_completer = AbooksCompleter([abook]) def test_empty_address_book_returns_empty_list(self): actual = self.__class__.empty_abook_completer.complete('real-name', 9) expected = [] self.assertListEqual(actual, expected) def _assert_only_one_list_entry(self, actual, expected): """Check that the given lists are both of length 1 and the tuple at the first positions are equal.""" self.assertEqual(len(actual), 1) self.assertEqual(len(expected), 1) self.assertTupleEqual(actual[0], expected[0]) def test_empty_real_name_returns_plain_email_address(self): actual = self.__class__.example_abook_completer.complete( "real-name", 9) expected = [("no-real-name@example.com", 24)] self._assert_only_one_list_entry(actual, expected) def test_simple_address_with_real_name(self): actual = self.__class__.example_abook_completer.complete("foo", 3) expected = [("foo ", 21)] self.assertListEqual(actual, expected) def test_simple_address_with_umlaut_real_name(self): actual = self.__class__.example_abook_completer.complete("umlaut", 6) expected = [("Ümläut ", 27)] self.assertListEqual(actual, expected) def test_real_name_with_comma(self): actual = self.__class__.example_abook_completer.complete("comma", 5) expected = [('"comma, person" ', 35)] self.assertListEqual(actual, expected) def test_real_name_with_single_quotes(self): actual = self.__class__.example_abook_completer.complete("squote", 6) expected = [("single 'quote' person ", 42)] self._assert_only_one_list_entry(actual, expected) def test_real_name_double_quotes(self): actual = self.__class__.example_abook_completer.complete("dquote", 6) expected = [("", 0)] expected = [ (r"""double "quote" person """, 42)] self._assert_only_one_list_entry(actual, expected) def test_real_name_with_quotes_and_comma(self): actual = self.__class__.example_abook_completer.complete("all", 3) expected = [(r""""all 'fanzy' "stuff" at, once" """, 48)] self._assert_only_one_list_entry(actual, expected) class StringlistCompleterTest(unittest.TestCase): def test_dont_choke_on_special_regex_characters(self): tags = ['[match]', 'nomatch'] completer = StringlistCompleter(tags) actual = completer.complete('[', 1) expected = [(tags[0], len(tags[0]))] self.assertListEqual(actual, expected) alot-0.11/tests/test_crypto.py000066400000000000000000000323641466311112200164720ustar00rootroot00000000000000# Copyright (C) 2017 Lucas Hoffmann # Copyright © 2017-2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import os import shutil import signal import subprocess import tempfile import unittest from unittest import mock import gpg import urwid from alot import crypto from alot.errors import GPGProblem, GPGCode from . import utilities MOD_CLEAN = utilities.ModuleCleanup() # A useful single fingerprint for tests that only care about one key. This # key will not be ambiguous FPR = "F74091D4133F87D56B5D343C1974EC55FBC2D660" # Some additional keys, these keys may be ambigiuos EXTRA_FPRS = [ "DD19862809A7573A74058FF255937AFBB156245D", "2071E9C8DB4EF5466F4D233CF730DF92C4566CE7", ] DEVNULL = open('/dev/null', 'w') MOD_CLEAN.add_cleanup(DEVNULL.close) @MOD_CLEAN.wrap_setup def setUpModule(): home = tempfile.mkdtemp() MOD_CLEAN.add_cleanup(shutil.rmtree, home) mock_home = mock.patch.dict(os.environ, {'GNUPGHOME': home}) mock_home.start() MOD_CLEAN.add_cleanup(mock_home.stop) with gpg.core.Context(armor=True) as ctx: # Add the public and private keys. They have no password search_dir = os.path.join(os.path.dirname(__file__), 'static/gpg-keys') for each in os.listdir(search_dir): if os.path.splitext(each)[1] == '.gpg': with open(os.path.join(search_dir, each)) as f: ctx.op_import(f) @MOD_CLEAN.wrap_teardown def tearDownModule(): # Kill any gpg-agent's that have been opened lookfor = 'gpg-agent --homedir {}'.format(os.environ['GNUPGHOME']) out = subprocess.check_output( ['ps', 'xo', 'pid,cmd'], stderr=DEVNULL).decode(urwid.util.detected_encoding) for each in out.strip().split('\n'): pid, cmd = each.strip().split(' ', 1) if cmd.startswith(lookfor): os.kill(int(pid), signal.SIGKILL) def make_key(revoked=False, expired=False, invalid=False, can_encrypt=True, can_sign=True): # This is ugly mock_key = mock.create_autospec(gpg._gpgme._gpgme_key) mock_key.uids = [mock.Mock(uid='mocked')] mock_key.revoked = revoked mock_key.expired = expired mock_key.invalid = invalid mock_key.can_encrypt = can_encrypt mock_key.can_sign = can_sign return mock_key def make_uid(email, revoked=False, invalid=False, validity=gpg.constants.validity.FULL): uid = mock.Mock() uid.email = email uid.revoked = revoked uid.invalid = invalid uid.validity = validity return uid class TestHashAlgorithmHelper(unittest.TestCase): """Test cases for the helper function RFC3156_canonicalize.""" def test_returned_string_starts_with_pgp(self): result = crypto.RFC3156_micalg_from_algo(gpg.constants.md.MD5) self.assertTrue(result.startswith('pgp-')) def test_returned_string_is_lower_case(self): result = crypto.RFC3156_micalg_from_algo(gpg.constants.md.MD5) self.assertTrue(result.islower()) def test_raises_for_unknown_hash_name(self): with self.assertRaises(GPGProblem): crypto.RFC3156_micalg_from_algo(gpg.constants.md.NONE) class TestDetachedSignatureFor(unittest.TestCase): def test_valid_signature_generated(self): to_sign = b"this is some text.\nit is more than nothing.\n" with gpg.core.Context() as ctx: _, detached = crypto.detached_signature_for( to_sign, [ctx.get_key(FPR)]) with tempfile.NamedTemporaryFile(delete=False) as f: f.write(detached) sig = f.name self.addCleanup(os.unlink, f.name) with tempfile.NamedTemporaryFile(delete=False) as f: f.write(to_sign) text = f.name self.addCleanup(os.unlink, f.name) res = subprocess.check_call(['gpg', '--verify', sig, text], stdout=DEVNULL, stderr=DEVNULL) self.assertEqual(res, 0) class TestVerifyDetached(unittest.TestCase): def test_verify_signature_good(self): to_sign = b"this is some text.\nIt's something\n." with gpg.core.Context() as ctx: _, detached = crypto.detached_signature_for( to_sign, [ctx.get_key(FPR)]) try: crypto.verify_detached(to_sign, detached) except GPGProblem: raise AssertionError def test_verify_signature_bad(self): to_sign = b"this is some text.\nIt's something\n." similar = b"this is some text.\r\n.It's something\r\n." with gpg.core.Context() as ctx: _, detached = crypto.detached_signature_for( to_sign, [ctx.get_key(FPR)]) with self.assertRaises(GPGProblem): crypto.verify_detached(similar, detached) class TestValidateKey(unittest.TestCase): def test_valid(self): try: crypto.validate_key(utilities.make_key()) except GPGProblem as e: raise AssertionError(e) def test_revoked(self): with self.assertRaises(GPGProblem) as caught: crypto.validate_key(utilities.make_key(revoked=True)) self.assertEqual(caught.exception.code, GPGCode.KEY_REVOKED) def test_expired(self): with self.assertRaises(GPGProblem) as caught: crypto.validate_key(utilities.make_key(expired=True)) self.assertEqual(caught.exception.code, GPGCode.KEY_EXPIRED) def test_invalid(self): with self.assertRaises(GPGProblem) as caught: crypto.validate_key(utilities.make_key(invalid=True)) self.assertEqual(caught.exception.code, GPGCode.KEY_INVALID) def test_encrypt(self): with self.assertRaises(GPGProblem) as caught: crypto.validate_key( utilities.make_key(can_encrypt=False), encrypt=True) self.assertEqual(caught.exception.code, GPGCode.KEY_CANNOT_ENCRYPT) def test_encrypt_no_check(self): try: crypto.validate_key(utilities.make_key(can_encrypt=False)) except GPGProblem as e: raise AssertionError(e) def test_sign(self): with self.assertRaises(GPGProblem) as caught: crypto.validate_key(utilities.make_key(can_sign=False), sign=True) self.assertEqual(caught.exception.code, GPGCode.KEY_CANNOT_SIGN) def test_sign_no_check(self): try: crypto.validate_key(utilities.make_key(can_sign=False)) except GPGProblem as e: raise AssertionError(e) class TestCheckUIDValidity(unittest.TestCase): def test_valid_single(self): key = utilities.make_key() key.uids[0] = utilities.make_uid(mock.sentinel.EMAIL) ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL) self.assertTrue(ret) def test_valid_multiple(self): key = utilities.make_key() key.uids = [ utilities.make_uid(mock.sentinel.EMAIL), utilities.make_uid(mock.sentinel.EMAIL1), ] ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL1) self.assertTrue(ret) def test_invalid_email(self): key = utilities.make_key() key.uids[0] = utilities.make_uid(mock.sentinel.EMAIL) ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL1) self.assertFalse(ret) def test_invalid_revoked(self): key = utilities.make_key() key.uids[0] = utilities.make_uid(mock.sentinel.EMAIL, revoked=True) ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL) self.assertFalse(ret) def test_invalid_invalid(self): key = utilities.make_key() key.uids[0] = utilities.make_uid(mock.sentinel.EMAIL, invalid=True) ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL) self.assertFalse(ret) def test_invalid_not_enough_trust(self): key = utilities.make_key() key.uids[0] = utilities.make_uid( mock.sentinel.EMAIL, validity=gpg.constants.validity.UNDEFINED) ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL) self.assertFalse(ret) class TestListKeys(unittest.TestCase): def test_list_no_hints(self): # This only tests that you get 3 keys back (the number in our test # keyring), it might be worth adding tests to check more about the keys # returned values = crypto.list_keys() self.assertEqual(len(list(values)), 3) def test_list_hint(self): values = crypto.list_keys(hint="ambig") self.assertEqual(len(list(values)), 2) def test_list_keys_pub(self): values = list(crypto.list_keys(hint="ambigu"))[0] self.assertEqual(values.uids[0].email, 'amigbu@example.com') self.assertFalse(values.secret) def test_list_keys_private(self): values = list(crypto.list_keys(hint="ambigu", private=True))[0] self.assertEqual(values.uids[0].email, 'amigbu@example.com') self.assertTrue(values.secret) class TestGetKey(unittest.TestCase): def test_plain(self): # Test the uid of the only identity attached to the key we generated. with gpg.core.Context() as ctx: expected = ctx.get_key(FPR).uids[0].uid actual = crypto.get_key(FPR).uids[0].uid self.assertEqual(expected, actual) def test_validate(self): # Since we already test validation we're only going to test validate # once. with gpg.core.Context() as ctx: expected = ctx.get_key(FPR).uids[0].uid actual = crypto.get_key( FPR, validate=True, encrypt=True, sign=True).uids[0].uid self.assertEqual(expected, actual) def test_missing_key(self): with self.assertRaises(GPGProblem) as caught: crypto.get_key('foo@example.com') self.assertEqual(caught.exception.code, GPGCode.NOT_FOUND) def test_invalid_key(self): with self.assertRaises(GPGProblem) as caught: crypto.get_key('z') self.assertEqual(caught.exception.code, GPGCode.NOT_FOUND) @mock.patch('alot.crypto.check_uid_validity', mock.Mock(return_value=True)) def test_signed_only_true(self): try: crypto.get_key(FPR, signed_only=True) except GPGProblem as e: raise AssertionError(e) @mock.patch( 'alot.crypto.check_uid_validity', mock.Mock(return_value=False)) def test_signed_only_false(self): with self.assertRaises(GPGProblem) as e: crypto.get_key(FPR, signed_only=True) self.assertEqual(e.exception.code, GPGCode.NOT_FOUND) @staticmethod def _context_mock(): class CustomError(gpg.errors.GPGMEError): """A custom GPGMEError class that always has an errors code of AMBIGUOUS_NAME. """ def getcode(self): return gpg.errors.AMBIGUOUS_NAME context_mock = mock.Mock() context_mock.get_key = mock.Mock(side_effect=CustomError) return context_mock def test_ambiguous_one_valid(self): invalid_key = utilities.make_key(invalid=True) valid_key = utilities.make_key() with mock.patch('alot.crypto.gpg.core.Context', mock.Mock(return_value=self._context_mock())), \ mock.patch('alot.crypto.list_keys', mock.Mock(return_value=[valid_key, invalid_key])): key = crypto.get_key('placeholder') self.assertIs(key, valid_key) def test_ambiguous_two_valid(self): with mock.patch('alot.crypto.gpg.core.Context', mock.Mock(return_value=self._context_mock())), \ mock.patch('alot.crypto.list_keys', mock.Mock(return_value=[utilities.make_key(), utilities.make_key()])): with self.assertRaises(crypto.GPGProblem) as cm: crypto.get_key('placeholder') self.assertEqual(cm.exception.code, GPGCode.AMBIGUOUS_NAME) def test_ambiguous_no_valid(self): with mock.patch('alot.crypto.gpg.core.Context', mock.Mock(return_value=self._context_mock())), \ mock.patch('alot.crypto.list_keys', mock.Mock(return_value=[ utilities.make_key(invalid=True), utilities.make_key(invalid=True)])): with self.assertRaises(crypto.GPGProblem) as cm: crypto.get_key('placeholder') self.assertEqual(cm.exception.code, GPGCode.NOT_FOUND) class TestEncrypt(unittest.TestCase): def test_encrypt(self): to_encrypt = b"this is a string\nof data." encrypted = crypto.encrypt(to_encrypt, keys=[crypto.get_key(FPR)]) with tempfile.NamedTemporaryFile(delete=False) as f: f.write(encrypted) enc_file = f.name self.addCleanup(os.unlink, enc_file) dec = subprocess.check_output( ['gpg', '--decrypt', enc_file], stderr=DEVNULL) self.assertEqual(to_encrypt, dec) class TestDecrypt(unittest.TestCase): def test_decrypt(self): to_encrypt = b"this is a string\nof data." encrypted = crypto.encrypt(to_encrypt, keys=[crypto.get_key(FPR)]) _, dec = crypto.decrypt_verify(encrypted) self.assertEqual(to_encrypt, dec) # TODO: test for "combined" method alot-0.11/tests/test_helper.py000066400000000000000000000404201466311112200164210ustar00rootroot00000000000000# encoding=utf-8 # Copyright © 2016-2018 Dylan Baker # Copyright © 2017 Lucas Hoffman # 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 . """Test suite for alot.helper module.""" import datetime import errno import os import random import unittest from unittest import mock from alot import helper from . import utilities # Descriptive names for tests often violate PEP8. That's not an issue, users # aren't meant to call these functions. # pylint: disable=invalid-name # They're tests, only add docstrings when it makes sense # pylint: disable=missing-docstring class TestHelperShortenAuthorString(unittest.TestCase): authors = 'King Kong, Mucho Muchacho, Jaime Huerta, Flash Gordon' def test_high_maxlength_keeps_string_intact(self): short = helper.shorten_author_string(self.authors, 60) self.assertEqual(short, self.authors) def test_shows_only_first_names_if_they_fit(self): short = helper.shorten_author_string(self.authors, 40) self.assertEqual(short, "King, Mucho, Jaime, Flash") def test_adds_ellipses_to_long_first_names(self): short = helper.shorten_author_string(self.authors, 20) self.assertEqual(short, "King, …, Jai…, Flash") def test_replace_all_but_first_name_with_ellipses(self): short = helper.shorten_author_string(self.authors, 10) self.assertEqual(short, "King, …") def test_shorten_first_name_with_ellipses(self): short = helper.shorten_author_string(self.authors, 2) self.assertEqual(short, "K…") def test_only_display_initial_letter_for_maxlength_1(self): short = helper.shorten_author_string(self.authors, 1) self.assertEqual(short, "K") class TestShellQuote(unittest.TestCase): def test_all_strings_are_sourrounded_by_single_quotes(self): quoted = helper.shell_quote("hello") self.assertEqual(quoted, "'hello'") def test_single_quotes_are_escaped_using_double_quotes(self): quoted = helper.shell_quote("hello'there") self.assertEqual(quoted, """'hello'"'"'there'""") class TestHumanizeSize(unittest.TestCase): def test_small_numbers_are_converted_to_strings_directly(self): readable = helper.humanize_size(1) self.assertEqual(readable, "1") readable = helper.humanize_size(123) self.assertEqual(readable, "123") def test_numbers_above_1024_are_converted_to_kilobyte(self): readable = helper.humanize_size(1023) self.assertEqual(readable, "1023") readable = helper.humanize_size(1024) self.assertEqual(readable, "1KiB") readable = helper.humanize_size(1234) self.assertEqual(readable, "1KiB") def test_numbers_above_1048576_are_converted_to_megabyte(self): readable = helper.humanize_size(1024*1024-1) self.assertEqual(readable, "1023KiB") readable = helper.humanize_size(1024*1024) self.assertEqual(readable, "1.0MiB") def test_megabyte_numbers_are_converted_with_precision_1(self): readable = helper.humanize_size(1234*1024) self.assertEqual(readable, "1.2MiB") def test_numbers_are_not_converted_to_gigabyte(self): readable = helper.humanize_size(1234*1024*1024) self.assertEqual(readable, "1234.0MiB") class TestSplitCommandline(unittest.TestCase): def _test(self, base, expected): """Shared helper to reduce some boilerplate.""" actual = helper.split_commandline(base) self.assertListEqual(actual, expected) def test_simple(self): base = 'echo "foo";sleep 1' expected = ['echo "foo"', 'sleep 1'] self._test(base, expected) def test_single(self): base = 'echo "foo bar"' expected = [base] self._test(base, expected) def test_quoted_separator(self): base = '''echo "foo; bar";sleep 1 ; echo "foo; \\"bar; baz";''' \ ''' echo "foo; bar \\\\" "baz"; test 'hi' 'hi';''' \ ''' word; two words; empty '' ''' expected = [ 'echo "foo; bar"', 'sleep 1 ', ' echo "foo; \\"bar; baz"', ' echo "foo; bar \\\\" "baz"', " test 'hi' 'hi'", ' word', ' two words', " empty '' " ] self._test(base, expected) class TestSplitCommandstring(unittest.TestCase): def _test(self, base, expected): """Shared helper to reduce some boilerplate.""" actual = helper.split_commandstring(base) self.assertListEqual(actual, expected) def test_bytes(self): base = 'echo "foo bar"' expected = ['echo', 'foo bar'] self._test(base, expected) def test_unicode(self): base = 'echo "foo €"' expected = ['echo', 'foo €'] self._test(base, expected) class TestStringSanitize(unittest.TestCase): def test_tabs(self): base = 'foo\tbar\noink\n' expected = 'foo' + ' ' * 5 + 'bar\noink\n' actual = helper.string_sanitize(base) self.assertEqual(actual, expected) def test_control_characters(self): base = 'foo\u009dbar\u0007\rtest' expected = 'foobartest' actual = helper.string_sanitize(base) self.assertEqual(actual, expected) class TestStringDecode(unittest.TestCase): def _test(self, base, expected, encoding='ascii'): actual = helper.string_decode(base, encoding) self.assertEqual(actual, expected) def test_ascii_bytes(self): base = 'test'.encode('ascii') expected = 'test' self._test(base, expected) def test_utf8_bytes(self): base = 'test'.encode('utf-8') expected = 'test' self._test(base, expected, 'utf-8') def test_unicode(self): base = 'test' expected = 'test' self._test(base, expected) class TestPrettyDatetime(unittest.TestCase): # TODO: Currently these tests use the ampm format based on whether or not # the testing machine's locale sets them. To be really good mock should be # used to change the locale between an am/pm locale and a 24 hour locale # and test both scenarios. __patchers = [] @classmethod def setUpClass(cls): # Create a random number generator, but seed it so that it will produce # deterministic output. This is used to select a subset of possible # values for each of the tests in this class, since otherwise they # would get really expensive (time wise). cls.random = random.Random() cls.random.seed(42) # Pick an exact date to ensure that the tests run the same no matter # what time of day they're run. cls.now = datetime.datetime(2000, 1, 5, 12, 0, 0, 0) # Mock datetime.now, which ensures that the time is always the same # removing race conditions from the tests. dt = mock.Mock() dt.now = mock.Mock(return_value=cls.now) cls.__patchers.append(mock.patch('alot.helper.datetime', dt)) for p in cls.__patchers: p.start() @classmethod def tearDownClass(cls): for p in cls.__patchers: p.stop() def test_just_now(self): for i in (self.random.randint(0, 60) for _ in range(5)): test = self.now - datetime.timedelta(seconds=i) actual = helper.pretty_datetime(test) self.assertEqual(actual, 'just now') def test_x_minutes_ago(self): for i in (self.random.randint(60, 3600) for _ in range(10)): test = self.now - datetime.timedelta(seconds=i) actual = helper.pretty_datetime(test) self.assertEqual( actual, '{}min ago'.format((self.now - test).seconds // 60)) def test_x_hours_ago(self): for i in (self.random.randint(3600, 3600 * 6) for _ in range(10)): test = self.now - datetime.timedelta(seconds=i) actual = helper.pretty_datetime(test) self.assertEqual( actual, '{}h ago'.format((self.now - test).seconds // 3600)) # TODO: yesterday # TODO: yesterday > now > a year # TODO: last year # XXX: when can the last else be hit? @staticmethod def _future_expected(test): if test.strftime('%p'): expected = test.strftime('%I:%M%p').lower() else: expected = test.strftime('%H:%M') expected = expected return expected def test_future_seconds(self): test = self.now + datetime.timedelta(seconds=30) actual = helper.pretty_datetime(test) expected = self._future_expected(test) self.assertEqual(actual, expected) # Returns 'just now', instead of 'from future' or something similar @unittest.expectedFailure def test_future_minutes(self): test = self.now + datetime.timedelta(minutes=5) actual = helper.pretty_datetime(test) expected = test.strftime('%a ') + self._future_expected(test) self.assertEqual(actual, expected) # Returns 'just now', instead of 'from future' or something similar @unittest.expectedFailure def test_future_hours(self): test = self.now + datetime.timedelta(hours=1) actual = helper.pretty_datetime(test) expected = test.strftime('%a ') + self._future_expected(test) self.assertEqual(actual, expected) # Returns 'just now', instead of 'from future' or something similar @unittest.expectedFailure def test_future_days(self): def make_expected(): # Uses the hourfmt instead of the hourminfmt from pretty_datetime if test.strftime('%p'): expected = test.strftime('%I%p') else: expected = test.strftime('%Hh') expected = expected.decode('utf-8') return expected test = self.now + datetime.timedelta(days=1) actual = helper.pretty_datetime(test) expected = test.strftime('%a ') + make_expected() self.assertEqual(actual, expected) # Returns 'just now', instead of 'from future' or something similar @unittest.expectedFailure def test_future_week(self): test = self.now + datetime.timedelta(days=7) actual = helper.pretty_datetime(test) expected = test.strftime('%b %d') self.assertEqual(actual, expected) # Returns 'just now', instead of 'from future' or something similar @unittest.expectedFailure def test_future_month(self): test = self.now + datetime.timedelta(days=31) actual = helper.pretty_datetime(test) expected = test.strftime('%b %d') self.assertEqual(actual, expected) # Returns 'just now', instead of 'from future' or something similar @unittest.expectedFailure def test_future_year(self): test = self.now + datetime.timedelta(days=365) actual = helper.pretty_datetime(test) expected = test.strftime('%b %Y') self.assertEqual(actual, expected) class TestCallCmd(unittest.TestCase): """Tests for the call_cmd function.""" def test_no_stdin(self): out, err, code = helper.call_cmd(['echo', '-n', 'foo']) self.assertEqual(out, 'foo') self.assertEqual(err, '') self.assertEqual(code, 0) def test_no_stdin_unicode(self): out, err, code = helper.call_cmd(['echo', '-n', '�']) self.assertEqual(out, '�') self.assertEqual(err, '') self.assertEqual(code, 0) def test_stdin(self): out, err, code = helper.call_cmd(['cat'], stdin='�') self.assertEqual(out, '�') self.assertEqual(err, '') self.assertEqual(code, 0) def test_no_such_command(self): out, err, code = helper.call_cmd(['thiscommandabsolutelydoesntexist']) self.assertEqual(out, '') # We don't control the output of err, the shell does. Therefore simply # assert that the shell said *something* self.assertNotEqual(err, '') self.assertEqual(code, errno.ENOENT) def test_no_such_command_stdin(self): out, err, code = helper.call_cmd(['thiscommandabsolutelydoesntexist'], stdin='foo') self.assertEqual(out, '') # We don't control the output of err, the shell does. Therefore simply # assert that the shell said *something* self.assertNotEqual(err, '') self.assertEqual(code, errno.ENOENT) def test_bad_argument_stdin(self): out, err, code = helper.call_cmd(['cat', '-Y'], stdin='�') self.assertEqual(out, '') self.assertNotEqual(err, '') # We don't control this, although 1 might be a fairly safe guess, we # know for certain it should *not* return 0 self.assertNotEqual(code, 0) def test_bad_argument(self): out, err, code = helper.call_cmd(['cat', '-Y']) self.assertEqual(out, '') self.assertNotEqual(err, '') # We don't control this, although 1 might be a fairly safe guess, we # know for certain it should *not* return 0 self.assertNotEqual(code, 0) def test_os_errors_from_popen_are_caught(self): with mock.patch('subprocess.Popen', mock.Mock(side_effect=OSError(42, 'foobar'))): out, err, code = helper.call_cmd( ['does_not_matter_as_subprocess_popen_is_mocked']) self.assertEqual(out, '') self.assertEqual(err, 'foobar') self.assertEqual(code, 42) class TestShorten(unittest.TestCase): def test_lt_maxlen(self): expected = 'a string' actual = helper.shorten(expected, 25) self.assertEqual(expected, actual) def test_eq_maxlen(self): expected = 'a string' actual = helper.shorten(expected, len(expected)) self.assertEqual(expected, actual) def test_gt_maxlen(self): expected = 'a long string…' actual = helper.shorten('a long string that is full of text', 14) self.assertEqual(expected, actual) class TestCallCmdAsync(unittest.TestCase): @utilities.async_test async def test_no_stdin(self): ret = await helper.call_cmd_async(['echo', '-n', 'foo']) self.assertEqual(ret[0], 'foo') @utilities.async_test async def test_stdin(self): ret = await helper.call_cmd_async(['cat', '-'], stdin='foo') self.assertEqual(ret[0], 'foo') @utilities.async_test async def test_command_fails(self): _, err, ret = await helper.call_cmd_async(['_____better_not_exist']) self.assertEqual(ret, 1) self.assertTrue(err) class TestGetEnv(unittest.TestCase): env_name = 'XDG_CONFIG_HOME' default = '~/.config' def test_env_not_set(self): with mock.patch.dict('os.environ'): if self.env_name in os.environ: del os.environ[self.env_name] self.assertEqual(helper.get_xdg_env(self.env_name, self.default), self.default) def test_env_empty(self): with mock.patch.dict('os.environ', {self.env_name: ''}): self.assertEqual(helper.get_xdg_env(self.env_name, self.default), self.default) def test_env_not_empty(self): custom_path = '/my/personal/config/home' with mock.patch.dict('os.environ', {self.env_name: custom_path}): self.assertEqual(helper.get_xdg_env(self.env_name, self.default), custom_path) class TestParseMailto(unittest.TestCase): def test_parsing_working(self): uri = 'mailto:test%40example.org?Subject=Re%3A%20Hello\ &In-Reply-To=%3CC8CE9EFD-CB23-4BC0-B70D-9B7FEAD59F8C%40example.org%3E' actual = helper.parse_mailto(uri) expected = ({'To': ['test@example.org'], 'Subject': ['Re: Hello'], 'In-reply-to': ['']}, '') self.assertEqual(actual, expected) alot-0.11/tests/utilities.py000066400000000000000000000134311466311112200161200ustar00rootroot00000000000000# encoding=utf-8 # Copyright © 2017 Dylan Baker # # 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 . """Helpers for unittests themselves.""" import asyncio import functools import unittest from unittest import mock import gpg def _tear_down_class_wrapper(original, cls): """Ensure that doClassCleanups is called after tearDownClass.""" try: original() finally: cls.doClassCleanups() def _set_up_class_wrapper(original, cls): """If setUpClass fails, call doClassCleanups.""" try: original() except Exception: cls.doClassCleanups() raise class TestCaseClassCleanup(unittest.TestCase): """A subclass of unittest.TestCase which adds classlevel clenups methods. """ __stack = [] def __new__(cls, _): """Wrap the tearDownClass method to esnure that doClassCleanups gets called. Because doCleanups (the test instance level version of this functionality) is called in code we can't supclass we need to do some hacking to ensure it's called. that hackery is in the form of wrapping the call to tearDownClass and setupClass methods. """ original = cls.tearDownClass # Get a unique object, otherwise functools.update_wrapper will always # act on the same object. We're using functools.partial as a proxy to # receive that information. # # We're also passing the original implementation to the wrapper # function as an argument, because it is being passed an unbound class # method the calling function will need to pass cls to it as an # argument. unique = functools.partial(_tear_down_class_wrapper, original, cls) # the classmethod decorator hides the __module__ attribute, so don't # try to set it. In python 3.x this is no longer true and the lat # parameter of this call can be removed functools.update_wrapper(unique, original, ['__name__', '__doc__']) # Repalce the orinal tearDownClass method with our wrapper cls.tearDownClass = unique # Do essentially the same thing for setup, but to ensure that # doClassCleanups is only called if there is an exception in the # setUpClass method. original = cls.setUpClass unique = functools.partial(_set_up_class_wrapper, original, cls) functools.update_wrapper(unique, original, ['__name__', '__doc__']) cls.setUpClass = unique return unittest.TestCase.__new__(cls) @classmethod def addClassCleanup(cls, function, *args, **kwargs): # pylint: disable=invalid-name cls.__stack.append((function, args, kwargs)) @classmethod def doClassCleanups(cls): # pylint: disable=invalid-name cls.tearDown_exceptions = [] while cls.__stack: func, args, kwargs = cls.__stack.pop() # TODO: Should exceptions be ignored from this? # TODO: addCleanups success if part of the success of the test, # what should we do here? func(*args, **kwargs) class ModuleCleanup(object): """Class for managing module level setup and teardown fixtures. Because of the way unittest is implemented it's rather difficult to write elegent fixtures because setUpModule and tearDownModule must exist when the module is initialized, so you can't do things like assign the methods to setUpModule and tearDownModule, nor can you just do some globals manipulation. """ def __init__(self): self.__stack = [] def do_cleanups(self): while self.__stack: func, args, kwargs = self.__stack.pop() func(*args, **kwargs) def add_cleanup(self, func, *args, **kwargs): self.__stack.append((func, args, kwargs)) def wrap_teardown(self, teardown): @functools.wraps(teardown) def wrapper(): try: teardown() finally: self.do_cleanups() return wrapper def wrap_setup(self, setup): @functools.wraps(setup) def wrapper(): try: setup() except Exception: self.do_cleanups() raise return wrapper def make_uid(email, uid='mocked', revoked=False, invalid=False, validity=gpg.constants.validity.FULL): uid_ = mock.Mock() uid_.email = email uid_.uid = uid uid_.revoked = revoked uid_.invalid = invalid uid_.validity = validity return uid_ def make_key(revoked=False, expired=False, invalid=False, can_encrypt=True, can_sign=True): mock_key = mock.Mock() mock_key.uids = [make_uid('foo@example.com')] mock_key.revoked = revoked mock_key.expired = expired mock_key.invalid = invalid mock_key.can_encrypt = can_encrypt mock_key.can_sign = can_sign return mock_key def make_ui(**kwargs): ui = mock.Mock(**kwargs) ui.paused.return_value = mock.MagicMock() return ui def async_test(coro): """Run an asyncrounous test synchronously.""" @functools.wraps(coro) def _actual(*args, **kwargs): loop = asyncio.get_event_loop() return loop.run_until_complete(coro(*args, **kwargs)) return _actual alot-0.11/tests/utils/000077500000000000000000000000001466311112200146715ustar00rootroot00000000000000alot-0.11/tests/utils/__init__.py000066400000000000000000000000001466311112200167700ustar00rootroot00000000000000alot-0.11/tests/utils/test_argparse.py000066400000000000000000000134751466311112200201200ustar00rootroot00000000000000# encoding=utf-8 # Copyright © 2017 Dylan Baker # 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 . """Tests for alot.utils.argparse""" import argparse import contextlib import os import shutil import tempfile import unittest from unittest import mock from alot.utils import argparse as cargparse # Good descriptive test names often don't fit PEP8, which is meant to cover # functions meant to be called by humans. # pylint: disable=invalid-name # When using mock asserts its possible that many methods will not use self, # that's fine # pylint: disable=no-self-use class TestValidatedStore(unittest.TestCase): """Tests for the ValidatedStore action class.""" def _argparse(self, args): """Create an argparse instance with a validator.""" def validator(args): if args == 'fail': raise cargparse.ValidationFailed parser = argparse.ArgumentParser() parser.add_argument( 'foo', action=cargparse.ValidatedStoreAction, validator=validator) with mock.patch('sys.stderr', mock.Mock()): return parser.parse_args(args) def test_validates(self): # Arparse will raise a SystemExit (calls sys.exit) rather than letting # the exception cause the program to close. with self.assertRaises(SystemExit): self._argparse(['fail']) @contextlib.contextmanager def temporary_directory(suffix='', prefix='', dir=None): # pylint: disable=redefined-builtin """Python3 interface implementation. Python3 provides a class that can be used as a context manager, which creates a temporary directory and removes it when the context manager exits. This function emulates enough of the interface of TemporaryDirectory, for this module to use, and is designed as a drop in replacement that can be replaced after the python3 port. The only user visible difference is that this does not implement the cleanup method that TemporaryDirectory does. """ directory = tempfile.mkdtemp(suffix=suffix, prefix=prefix, dir=dir) yield directory shutil.rmtree(directory) class TestRequireFile(unittest.TestCase): """Tests for the require_file validator.""" def test_doesnt_exist(self): with temporary_directory() as d: with self.assertRaises(cargparse.ValidationFailed): cargparse.require_file(os.path.join(d, 'doesnt-exist')) def test_dir(self): with temporary_directory() as d: with self.assertRaises(cargparse.ValidationFailed): cargparse.require_file(d) def test_file(self): with tempfile.NamedTemporaryFile() as f: cargparse.require_file(f.name) def test_char_special(self): with self.assertRaises(cargparse.ValidationFailed): cargparse.require_file('/dev/null') def test_fifo(self): with temporary_directory() as d: path = os.path.join(d, 'fifo') os.mkfifo(path) with self.assertRaises(cargparse.ValidationFailed): cargparse.require_file(path) class TestRequireDir(unittest.TestCase): """Tests for the require_dir validator.""" def test_doesnt_exist(self): with temporary_directory() as d: with self.assertRaises(cargparse.ValidationFailed): cargparse.require_dir(os.path.join(d, 'doesnt-exist')) def test_dir(self): with temporary_directory() as d: cargparse.require_dir(d) def test_file(self): with tempfile.NamedTemporaryFile() as f: with self.assertRaises(cargparse.ValidationFailed): cargparse.require_dir(f.name) def test_char_special(self): with self.assertRaises(cargparse.ValidationFailed): cargparse.require_dir('/dev/null') def test_fifo(self): with temporary_directory() as d: path = os.path.join(d, 'fifo') os.mkfifo(path) with self.assertRaises(cargparse.ValidationFailed): cargparse.require_dir(path) class TestOptionalFileLike(unittest.TestCase): """Tests for the optional_file_like validator.""" def test_doesnt_exist(self): with temporary_directory() as d: cargparse.optional_file_like(os.path.join(d, 'doesnt-exist')) def test_dir(self): with temporary_directory() as d: with self.assertRaises(cargparse.ValidationFailed): cargparse.optional_file_like(d) def test_file(self): with tempfile.NamedTemporaryFile() as f: cargparse.optional_file_like(f.name) def test_char_special(self): cargparse.optional_file_like('/dev/null') def test_fifo(self): with temporary_directory() as d: path = os.path.join(d, 'fifo') os.mkfifo(path) cargparse.optional_file_like(path) class TestIntOrPlusOrMinus(unittest.TestCase): """Tests for the is_int_or_pm validator.""" def test_int(self): self.assertTrue(cargparse.is_int_or_pm('5')) def test_pm(self): self.assertTrue(cargparse.is_int_or_pm('+')) self.assertTrue(cargparse.is_int_or_pm('-')) def test_rubbish(self): with self.assertRaises(cargparse.ValidationFailed): cargparse.is_int_or_pm('XX') alot-0.11/tests/utils/test_configobj.py000066400000000000000000000033161466311112200202450ustar00rootroot00000000000000# encoding=utf-8 import unittest from alot.utils import configobj as checks from validate import VdtTypeError, VdtValueError # Good descriptive test names often don't fit PEP8, which is meant to cover # functions meant to be called by humans. # pylint: disable=invalid-name class TestForceList(unittest.TestCase): def test_strings_are_converted_to_single_item_lists(self): forced = checks.force_list('hello') self.assertEqual(forced, ['hello']) def test_empty_strings_are_converted_to_empty_lists(self): forced = checks.force_list('') self.assertEqual(forced, []) class TestWidthTuple(unittest.TestCase): def test_validates_width_tuple(self): with self.assertRaises(VdtTypeError): checks.width_tuple('invalid-value') def test_validates_width_tuple_for_fit_requires_two_args(self): with self.assertRaises(VdtTypeError): checks.width_tuple(['fit', 123]) def test_args_for_fit_must_be_numbers(self): with self.assertRaises(VdtValueError): checks.width_tuple(['fit', 123, 'not-a-number']) def test_fit_with_two_numbers(self): fit_result = checks.width_tuple(['fit', 123, 456]) self.assertEqual(('fit', 123, 456), fit_result) def test_validates_width_tuple_for_weight_needs_an_argument(self): with self.assertRaises(VdtTypeError): checks.width_tuple(['weight']) def test_arg_for_width_must_be_a_number(self): with self.assertRaises(VdtValueError): checks.width_tuple(['weight', 'not-a-number']) def test_width_with_a_number(self): weight_result = checks.width_tuple(['weight', 123]) self.assertEqual(('weight', 123), weight_result) alot-0.11/tests/widgets/000077500000000000000000000000001466311112200151775ustar00rootroot00000000000000alot-0.11/tests/widgets/__init__.py000066400000000000000000000000001466311112200172760ustar00rootroot00000000000000alot-0.11/tests/widgets/test_globals.py000066400000000000000000000041371466311112200202400ustar00rootroot00000000000000# encoding=utf-8 # Copyright © 2017 Dylan Baker # 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 . """Tests for the alot.widgets.globals module.""" import unittest from unittest import mock from alot.widgets import globals as globals_ class TestTagWidget(unittest.TestCase): def test_sort(self): """Test sorting.""" # There's an upstream bug about this # pylint: disable=bad-continuation with mock.patch( 'alot.widgets.globals.settings.get_tagstring_representation', lambda t, _, __: {'translated': t, 'normal': None, 'focussed': None}): expected = ['a', 'z', 'aa', 'bar', 'foo'] actual = [g.translated for g in sorted(globals_.TagWidget(x) for x in expected)] self.assertListEqual(actual, expected) def test_hash_for_unicode_representation(self): with mock.patch( 'alot.widgets.globals.settings.get_tagstring_representation', lambda _, __, ___: {'translated': '✉', 'normal': None, 'focussed': None}): # We don't have to assert anything, we just want the hash to be # computed without an exception. The implementation currently # (2017-08-20) caches the hash value when __init__ is called. This # test should even test the correct thing if this is changed and # the hash is only computed in __hash__. hash(globals_.TagWidget('unread'))