pax_global_header 0000666 0000000 0000000 00000000064 14663111122 0014507 g ustar 00root root 0000000 0000000 52 comment=a8a108e2344656a13bca21211ccc0df2414cbef6
alot-0.11/ 0000775 0000000 0000000 00000000000 14663111122 0012367 5 ustar 00root root 0000000 0000000 alot-0.11/.codeclimate.yml 0000664 0000000 0000000 00000000304 14663111122 0015436 0 ustar 00root root 0000000 0000000 engines:
pep8:
enabled: true
fixme:
enabled: true
radon:
enabled: true
checks:
Complexity:
enabled: false
ratings:
paths:
- "**.py"
exclude_paths:
- tests/
alot-0.11/.github/ 0000775 0000000 0000000 00000000000 14663111122 0013727 5 ustar 00root root 0000000 0000000 alot-0.11/.github/ISSUE_TEMPLATE/ 0000775 0000000 0000000 00000000000 14663111122 0016112 5 ustar 00root root 0000000 0000000 alot-0.11/.github/ISSUE_TEMPLATE/bug_report.md 0000664 0000000 0000000 00000001311 14663111122 0020600 0 ustar 00root root 0000000 0000000 ---
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.md 0000664 0000000 0000000 00000001060 14663111122 0021634 0 ustar 00root root 0000000 0000000 ---
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.yml 0000664 0000000 0000000 00000000477 14663111122 0016715 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0015764 5 ustar 00root root 0000000 0000000 alot-0.11/.github/workflows/check.yml 0000664 0000000 0000000 00000003436 14663111122 0017572 0 ustar 00root root 0000000 0000000 name: 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.yml 0000664 0000000 0000000 00000005474 14663111122 0017500 0 ustar 00root root 0000000 0000000 name: 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/.gitignore 0000664 0000000 0000000 00000000257 14663111122 0014363 0 ustar 00root root 0000000 0000000 *.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.md 0000664 0000000 0000000 00000005143 14663111122 0014623 0 ustar 00root root 0000000 0000000 Getting 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/COPYING 0000664 0000000 0000000 00000104371 14663111122 0013430 0 ustar 00root root 0000000 0000000
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
alot-0.11/MANIFEST.in 0000664 0000000 0000000 00000000225 14663111122 0014124 0 ustar 00root root 0000000 0000000 include COPYING
include NEWS
include extra/completion/alot-completion.zsh
include extra/alot-mailto.desktop
include extra/alot.desktop
include tests
alot-0.11/NEWS 0000664 0000000 0000000 00000031344 14663111122 0013073 0 ustar 00root root 0000000 0000000 0.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.md 0000664 0000000 0000000 00000006730 14663111122 0013654 0 ustar 00root root 0000000 0000000 [![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/ 0000775 0000000 0000000 00000000000 14663111122 0013326 5 ustar 00root root 0000000 0000000 alot-0.11/alot/__init__.py 0000664 0000000 0000000 00000000426 14663111122 0015441 0 ustar 00root root 0000000 0000000 from 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__.py 0000664 0000000 0000000 00000013511 14663111122 0015421 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000033636 14663111122 0015347 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0015626 5 ustar 00root root 0000000 0000000 alot-0.11/alot/addressbook/__init__.py 0000664 0000000 0000000 00000002240 14663111122 0017735 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002151 14663111122 0017272 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000006411 14663111122 0020024 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0014762 5 ustar 00root root 0000000 0000000 alot-0.11/alot/buffers/__init__.py 0000664 0000000 0000000 00000000652 14663111122 0017076 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002020 14663111122 0016577 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000004567 14663111122 0017515 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000010652 14663111122 0017155 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000005015 14663111122 0020017 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000011501 14663111122 0016577 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000004547 14663111122 0017015 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000031554 14663111122 0016613 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0015127 5 ustar 00root root 0000000 0000000 alot-0.11/alot/commands/__init__.py 0000664 0000000 0000000 00000014111 14663111122 0017236 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001613 14663111122 0017647 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001747 14663111122 0017002 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000073546 14663111122 0017335 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000126107 14663111122 0017133 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001651 14663111122 0020166 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000024146 14663111122 0016755 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001133 14663111122 0017146 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000135223 14663111122 0016756 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000010276 14663111122 0016647 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0015477 5 ustar 00root root 0000000 0000000 alot-0.11/alot/completion/__init__.py 0000664 0000000 0000000 00000000000 14663111122 0017576 0 ustar 00root root 0000000 0000000 alot-0.11/alot/completion/abooks.py 0000664 0000000 0000000 00000002670 14663111122 0017334 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001267 14663111122 0017676 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000003032 14663111122 0017653 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000025366 14663111122 0017503 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000003552 14663111122 0020344 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001515 14663111122 0020332 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002251 14663111122 0020043 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001500 14663111122 0017663 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001415 14663111122 0020103 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000003453 14663111122 0021617 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001224 14663111122 0020222 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002530 14663111122 0017005 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000004663 14663111122 0017227 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002070 14663111122 0020252 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001053 14663111122 0016623 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001143 14663111122 0017006 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000030716 14663111122 0015227 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0013713 5 ustar 00root root 0000000 0000000 alot-0.11/alot/db/__init__.py 0000664 0000000 0000000 00000000346 14663111122 0016027 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000005670 14663111122 0016425 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000033563 14663111122 0016114 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001020 14663111122 0015572 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000041106 14663111122 0015701 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000025464 14663111122 0015724 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000024651 14663111122 0015544 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000053041 14663111122 0015430 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0015135 5 ustar 00root root 0000000 0000000 alot-0.11/alot/defaults/abook_contacts.spec 0000664 0000000 0000000 00000000204 14663111122 0020776 0 ustar 00root root 0000000 0000000 [format]
program = string
version = string
[__many__]
name = string(default=None)
email = force_list(default=list())
alot-0.11/alot/defaults/alot.rc.spec 0000664 0000000 0000000 00000051701 14663111122 0017357 0 ustar 00root root 0000000 0000000
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.bindings 0000664 0000000 0000000 00000003201 14663111122 0020274 0 ustar 00root root 0000000 0000000 up = 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.theme 0000664 0000000 0000000 00000012041 14663111122 0017603 0 ustar 00root root 0000000 0000000 ############################################################################
# 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.spec 0000664 0000000 0000000 00000004015 14663111122 0017113 0 ustar 00root root 0000000 0000000 [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.py 0000664 0000000 0000000 00000001272 14663111122 0015216 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000046263 14663111122 0015172 0 ustar 00root root 0000000 0000000 # -*- 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/ 0000775 0000000 0000000 00000000000 14663111122 0015166 5 ustar 00root root 0000000 0000000 alot-0.11/alot/settings/__init__.py 0000664 0000000 0000000 00000000000 14663111122 0017265 0 ustar 00root root 0000000 0000000 alot-0.11/alot/settings/const.py 0000664 0000000 0000000 00000000361 14663111122 0016666 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000000540 14663111122 0017053 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000047623 14663111122 0017166 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000012346 14663111122 0016650 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000012405 14663111122 0016702 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000075455 14663111122 0014335 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0014466 5 ustar 00root root 0000000 0000000 alot-0.11/alot/utils/__init__.py 0000664 0000000 0000000 00000000000 14663111122 0016565 0 ustar 00root root 0000000 0000000 alot-0.11/alot/utils/ansi.py 0000664 0000000 0000000 00000002343 14663111122 0015774 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000011122 14663111122 0016641 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000005771 14663111122 0020225 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001270 14663111122 0017356 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000011123 14663111122 0016776 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000006046 14663111122 0015173 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0014774 5 ustar 00root root 0000000 0000000 alot-0.11/alot/widgets/__init__.py 0000664 0000000 0000000 00000000000 14663111122 0017073 0 ustar 00root root 0000000 0000000 alot-0.11/alot/widgets/ansi.py 0000664 0000000 0000000 00000011106 14663111122 0016277 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001314 14663111122 0017512 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000033063 14663111122 0016776 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001514 14663111122 0020031 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000015760 14663111122 0016624 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000040306 14663111122 0016620 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002511 14663111122 0016505 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0013317 5 ustar 00root root 0000000 0000000 alot-0.11/docs/Makefile 0000664 0000000 0000000 00000012705 14663111122 0014764 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0014617 5 ustar 00root root 0000000 0000000 alot-0.11/docs/source/api/ 0000775 0000000 0000000 00000000000 14663111122 0015370 5 ustar 00root root 0000000 0000000 alot-0.11/docs/source/api/commands.rst 0000664 0000000 0000000 00000004202 14663111122 0017721 0 ustar 00root root 0000000 0000000 Commands
=========
.. 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.rst 0000664 0000000 0000000 00000000071 14663111122 0017440 0 ustar 00root root 0000000 0000000 Crypto
======
.. automodule:: alot.crypto
:members:
alot-0.11/docs/source/api/database.rst 0000664 0000000 0000000 00000003141 14663111122 0017665 0 ustar 00root root 0000000 0000000 Email 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.rst 0000664 0000000 0000000 00000000260 14663111122 0017227 0 ustar 00root root 0000000 0000000 API and Development
*******************
.. module:: alot
.. toctree::
:maxdepth: 1
overview
database
interface
settings
utils
commands
crypto
alot-0.11/docs/source/api/interface.rst 0000664 0000000 0000000 00000007435 14663111122 0020073 0 ustar 00root root 0000000 0000000 User 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.rst 0000664 0000000 0000000 00000002570 14663111122 0017774 0 ustar 00root root 0000000 0000000 Overview
========
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.rst 0000664 0000000 0000000 00000006000 14663111122 0017756 0 ustar 00root root 0000000 0000000 User 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.rst 0000664 0000000 0000000 00000000175 14663111122 0017265 0 ustar 00root root 0000000 0000000 Utils
=====
.. currentmodule:: alot.helper
.. automodule:: alot.helper
:members:
.. automodule:: alot.utils
:members:
alot-0.11/docs/source/conf.py 0000664 0000000 0000000 00000016277 14663111122 0016133 0 ustar 00root root 0000000 0000000 # -*- 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/ 0000775 0000000 0000000 00000000000 14663111122 0017466 5 ustar 00root root 0000000 0000000 alot-0.11/docs/source/configuration/accounts.rst 0000664 0000000 0000000 00000002415 14663111122 0022041 0 ustar 00root root 0000000 0000000 .. _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_table 0000664 0000000 0000000 00000013126 14663111122 0022402 0 ustar 00root root 0000000 0000000
.. 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_table 0000664 0000000 0000000 00000045424 14663111122 0022055 0 ustar 00root root 0000000 0000000
.. 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.rst 0000664 0000000 0000000 00000001437 14663111122 0023245 0 ustar 00root root 0000000 0000000 .. _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.rst 0000664 0000000 0000000 00000010065 14663111122 0024271 0 ustar 00root root 0000000 0000000 .. _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.rst 0000664 0000000 0000000 00000015777 14663111122 0021364 0 ustar 00root root 0000000 0000000 .. _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.rst 0000664 0000000 0000000 00000001112 14663111122 0021322 0 ustar 00root root 0000000 0000000 .. _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.rst 0000664 0000000 0000000 00000004573 14663111122 0022676 0 ustar 00root root 0000000 0000000 .. _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.rst 0000664 0000000 0000000 00000023304 14663111122 0021655 0 ustar 00root root 0000000 0000000 .. _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.rst 0000664 0000000 0000000 00000000333 14663111122 0017673 0 ustar 00root root 0000000 0000000 Alot 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.rst 0000664 0000000 0000000 00000011532 14663111122 0016122 0 ustar 00root root 0000000 0000000 Frequently 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.py 0000775 0000000 0000000 00000010234 14663111122 0020647 0 ustar 00root root 0000000 0000000 import 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.py 0000775 0000000 0000000 00000004751 14663111122 0020505 0 ustar 00root root 0000000 0000000 import 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.rst 0000664 0000000 0000000 00000000323 14663111122 0016456 0 ustar 00root root 0000000 0000000 Alot
====
.. 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.rst 0000664 0000000 0000000 00000005614 14663111122 0020060 0 ustar 00root root 0000000 0000000 Installation
************
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.rst 0000664 0000000 0000000 00000000571 14663111122 0016764 0 ustar 00root root 0000000 0000000 Manpage
=======
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/ 0000775 0000000 0000000 00000000000 14663111122 0015723 5 ustar 00root root 0000000 0000000 alot-0.11/docs/source/usage/cli_commands.rst 0000664 0000000 0000000 00000000574 14663111122 0021113 0 ustar 00root root 0000000 0000000 search
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.rst 0000664 0000000 0000000 00000001503 14663111122 0020776 0 ustar 00root root 0000000 0000000 -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.rst 0000664 0000000 0000000 00000002130 14663111122 0020252 0 ustar 00root root 0000000 0000000 Commands
========
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.rst 0000664 0000000 0000000 00000004727 14663111122 0020007 0 ustar 00root root 0000000 0000000 Cryptography
============
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.rst 0000664 0000000 0000000 00000001145 14663111122 0021023 0 ustar 00root root 0000000 0000000 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 keybindings for the current mode are listed upon pressing `?`.
alot-0.11/docs/source/usage/index.rst 0000664 0000000 0000000 00000001274 14663111122 0017570 0 ustar 00root root 0000000 0000000 *****
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/ 0000775 0000000 0000000 00000000000 14663111122 0017032 5 ustar 00root root 0000000 0000000 alot-0.11/docs/source/usage/modes/bufferlist.rst 0000664 0000000 0000000 00000000467 14663111122 0021740 0 ustar 00root root 0000000 0000000 .. 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.rst 0000664 0000000 0000000 00000006735 14663111122 0021414 0 ustar 00root root 0000000 0000000 .. 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.rst 0000664 0000000 0000000 00000007520 14663111122 0021030 0 ustar 00root root 0000000 0000000 .. 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.rst 0000664 0000000 0000000 00000000503 14663111122 0022244 0 ustar 00root root 0000000 0000000 .. 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.rst 0000664 0000000 0000000 00000005233 14663111122 0021034 0 ustar 00root root 0000000 0000000 .. 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.rst 0000664 0000000 0000000 00000000363 14663111122 0021235 0 ustar 00root root 0000000 0000000 .. 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.rst 0000664 0000000 0000000 00000011306 14663111122 0021034 0 ustar 00root root 0000000 0000000 .. 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.rst 0000664 0000000 0000000 00000000121 14663111122 0020107 0 ustar 00root root 0000000 0000000 SIGUSR1
Refreshes the current buffer.
SIGINT
Shuts down the user interface.
alot-0.11/docs/source/usage/synopsis.rst 0000664 0000000 0000000 00000000040 14663111122 0020336 0 ustar 00root root 0000000 0000000 alot [options ...] [subcommand]
alot-0.11/extra/ 0000775 0000000 0000000 00000000000 14663111122 0013512 5 ustar 00root root 0000000 0000000 alot-0.11/extra/alot-mailto.desktop 0000664 0000000 0000000 00000000345 14663111122 0017331 0 ustar 00root root 0000000 0000000 [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.desktop 0000664 0000000 0000000 00000000251 14663111122 0016042 0 ustar 00root root 0000000 0000000 [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.py 0000775 0000000 0000000 00000023021 14663111122 0016725 0 ustar 00root root 0000000 0000000 #!/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/ 0000775 0000000 0000000 00000000000 14663111122 0015663 5 ustar 00root root 0000000 0000000 alot-0.11/extra/completion/alot-completion.zsh 0000664 0000000 0000000 00000006110 14663111122 0021515 0 ustar 00root root 0000000 0000000 #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/ 0000775 0000000 0000000 00000000000 14663111122 0014635 5 ustar 00root root 0000000 0000000 alot-0.11/extra/hooks/external_command_tmux_without_x11.py 0000664 0000000 0000000 00000000607 14663111122 0024063 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000006571 14663111122 0016236 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14663111122 0014777 5 ustar 00root root 0000000 0000000 alot-0.11/extra/themes/mutt 0000664 0000000 0000000 00000007721 14663111122 0015722 0 ustar 00root root 0000000 0000000 ###############################################################################
# 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_dark 0000664 0000000 0000000 00000014661 14663111122 0017727 0 ustar 00root root 0000000 0000000 ###############################################################################
# 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_light 0000664 0000000 0000000 00000017171 14663111122 0020114 0 ustar 00root root 0000000 0000000 ###############################################################################
# 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/sup 0000664 0000000 0000000 00000010365 14663111122 0015536 0 ustar 00root root 0000000 0000000 ###############################################################################
# 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/tomorrow 0000664 0000000 0000000 00000017667 14663111122 0016633 0 ustar 00root root 0000000 0000000 # 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.sh 0000775 0000000 0000000 00000001001 14663111122 0022144 0 ustar 00root root 0000000 0000000 #!/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.lock 0000664 0000000 0000000 00000002731 14663111122 0014326 0 ustar 00root root 0000000 0000000 {
"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.nix 0000664 0000000 0000000 00000005173 14663111122 0014177 0 ustar 00root root 0000000 0000000 {
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.toml 0000664 0000000 0000000 00000002465 14663111122 0015312 0 ustar 00root root 0000000 0000000 [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.yaml 0000664 0000000 0000000 00000001461 14663111122 0015542 0 ustar 00root root 0000000 0000000 # 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.cfg 0000664 0000000 0000000 00000000074 14663111122 0014211 0 ustar 00root root 0000000 0000000 [pycodestyle]
count = False
ignore = E501
statistics = True
alot-0.11/tests/ 0000775 0000000 0000000 00000000000 14663111122 0013531 5 ustar 00root root 0000000 0000000 alot-0.11/tests/__init__.py 0000664 0000000 0000000 00000000000 14663111122 0015630 0 ustar 00root root 0000000 0000000 alot-0.11/tests/addressbook/ 0000775 0000000 0000000 00000000000 14663111122 0016031 5 ustar 00root root 0000000 0000000 alot-0.11/tests/addressbook/__init__.py 0000664 0000000 0000000 00000000000 14663111122 0020130 0 ustar 00root root 0000000 0000000 alot-0.11/tests/addressbook/test_abook.py 0000664 0000000 0000000 00000002271 14663111122 0020537 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000006067 14663111122 0021275 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000005326 14663111122 0020413 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0015332 5 ustar 00root root 0000000 0000000 alot-0.11/tests/commands/__init__.py 0000664 0000000 0000000 00000000000 14663111122 0017431 0 ustar 00root root 0000000 0000000 alot-0.11/tests/commands/test_envelope.py 0000664 0000000 0000000 00000033643 14663111122 0020571 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000016006 14663111122 0020206 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000003126 14663111122 0017710 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000015617 14663111122 0020224 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000016110 14663111122 0020265 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0014116 5 ustar 00root root 0000000 0000000 alot-0.11/tests/db/__init__.py 0000664 0000000 0000000 00000000000 14663111122 0016215 0 ustar 00root root 0000000 0000000 alot-0.11/tests/db/test_envelope.py 0000664 0000000 0000000 00000011556 14663111122 0017354 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000003653 14663111122 0017150 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000010077 14663111122 0017160 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000005245 14663111122 0017004 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000106331 14663111122 0016673 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0015371 5 ustar 00root root 0000000 0000000 alot-0.11/tests/settings/__init__.py 0000664 0000000 0000000 00000000000 14663111122 0017470 0 ustar 00root root 0000000 0000000 alot-0.11/tests/settings/test_manager.py 0000664 0000000 0000000 00000030701 14663111122 0020415 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000005731 14663111122 0020112 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000005156 14663111122 0020151 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14663111122 0015020 5 ustar 00root root 0000000 0000000 alot-0.11/tests/static/gpg-keys/ 0000775 0000000 0000000 00000000000 14663111122 0016546 5 ustar 00root root 0000000 0000000 alot-0.11/tests/static/gpg-keys/ambig1-pub.gpg 0000664 0000000 0000000 00000003305 14663111122 0021172 0 ustar 00root root 0000000 0000000 -----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.gpg 0000664 0000000 0000000 00000006652 14663111122 0021166 0 ustar 00root root 0000000 0000000 -----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.gpg 0000664 0000000 0000000 00000002307 14663111122 0021174 0 ustar 00root root 0000000 0000000
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 @7A ambigu N 8
! qNFoM#<0ߒVl]0
0ߒVl nblȈ8} xkhW@0,&