diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-23 21:15:13 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-23 21:15:13 +0200 |
| commit | fabcf080652c6e5d57bf234be5e100769a9b965b (patch) | |
| tree | 0de4222c1e2aab58c38d34c9e0e3c37c68298cc8 /assets/hooks/test_qtmaildirconf.py | |
| parent | c50bea78e036518ce1a2a3eb899bbb5e305affea (diff) | |
| parent | ddcae8d02ef46db522b3cf6c228196c7a66a6432 (diff) | |
| download | qtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.tar.gz qtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.zip | |
Merge branch 'compose-and-send': composing and sending mail
Item 123, built over 2026-08-20 to 2026-08-23 in thirteen tasks against
docs/superpowers/specs/2026-08-20-compose-and-send-design.md.
The application writes mail now. A composer window per message, markdown as
the body, drafts autosaving into the account's Maildir, and sending through a
per-account command on stdin rather than any network protocol of this
program's own. A countdown with an Undo stands between pressing Send and the
command running.
Two things came in alongside it. The notmuch auto-tagging hooks moved here
from the retiring `mailctl` project and learned that mail this application
files itself never arrived, so sent mail and drafts stop appearing in the
inbox. And the v1/v2 language is retired: semver on the user-visible surface
is the rule, and those labels described a split that composing made obsolete.
Hand tested against a fake send command rather than a real one, deliberately:
New, Reply and Forward all produce correct messages, a forwarded attachment
survives intact, and the sent copy is filed. That testing found the two
defects fixed on this branch, and both were invisible to the suite: a composer
orphaned by quitting the main window, and every sent message tagged `inbox`.
Twenty-two defects were found in the plan document's own draft code while
building it, which is why CLAUDE.md says to treat every code block in a plan
as a draft.
Diffstat (limited to 'assets/hooks/test_qtmaildirconf.py')
| -rwxr-xr-x | assets/hooks/test_qtmaildirconf.py | 179 |
1 files changed, 179 insertions, 0 deletions
diff --git a/assets/hooks/test_qtmaildirconf.py b/assets/hooks/test_qtmaildirconf.py new file mode 100755 index 0000000..c8aa78d --- /dev/null +++ b/assets/hooks/test_qtmaildirconf.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Unit checks for the qtmaildir.conf reader the post-new hook uses to find +the sent folders. + +The file is written by QSettings, not by configparser, so the cases that +matter are the ones where the two disagree: a section name carrying a dot, a +comment introduced by `;`, and a key present but empty. + +Run: ./test_qtmaildirconf.py +""" + +import tempfile +from pathlib import Path + +import qtmaildirconf + + +def write_config(tmp, text): + path = Path(tmp) / "qtmaildir" / "qtmaildir.conf" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return path + + +def test_sent_folders_are_read_per_account(): + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.work]\n" + "maildir = work\n" + "sent = Sent\n" + "trash = Trash\n") + assert qtmaildirconf.sent_folders(path) == ["work/Sent"] + + +def test_drafts_are_excluded_alongside_sent(): + """A draft never arrived either, so it must not carry `inbox`. Both keys + feed one list: the hook asks a single question, "is this a folder mail + arrives in", and sent and drafts answer it the same way. + + Trash is deliberately NOT here. qtmaildir's own Delete leaves `inbox` on + a trashed message so Restore can put it back where it came from, and + stripping it here would fight that. + """ + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.work]\n" + "maildir = work\n" + "sent = Sent\n" + "drafts = Drafts\n" + "trash = Trash\n") + assert qtmaildirconf.sent_folders(path) == ["work/Sent", "work/Drafts"] + + +def test_an_account_with_only_drafts_still_contributes(): + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.a]\nmaildir = a\ndrafts = Drafts\n") + assert qtmaildirconf.sent_folders(path) == ["a/Drafts"] + + +def test_an_account_section_may_carry_a_dot(): + """QSettings writes `[account.a.b]` for the key `a.b`, and the account + key is everything after the first dot. Splitting on the LAST dot names + an account that does not exist and finds no folder.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.provider.name]\n" + "maildir = provider-name\n" + "sent = Sent\n") + assert qtmaildirconf.sent_folders(path) == ["provider-name/Sent"] + + +def test_a_folder_may_contain_spaces_and_brackets(): + """`[Gmail]/Posta inviata` is a real folder name here. The brackets are + the provider's, not INI syntax, because they are in a VALUE.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.g]\n" + "maildir = gmail\n" + "sent = [Gmail]/Posta inviata\n") + assert qtmaildirconf.sent_folders(path) == [ + "gmail/[Gmail]/Posta inviata"] + + +def test_an_account_without_a_sent_key_contributes_nothing(): + """`sent` is optional: an account may keep no sent mail locally. It must + not contribute an entry, since a bare `maildir/` prefix would match the + whole account.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.a]\nmaildir = a\ntrash = Trash\n" + "[account.b]\nmaildir = b\nsent = Sent\n") + assert qtmaildirconf.sent_folders(path) == ["b/Sent"] + + +def test_an_empty_sent_value_contributes_nothing(): + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.a]\nmaildir = a\nsent =\n") + assert qtmaildirconf.sent_folders(path) == [] + + +def test_an_account_without_a_maildir_contributes_nothing(): + """Without the account's own subdirectory the folder cannot be located, + and a bare `Sent` would match every account's sent folder at once.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.a]\nsent = Sent\n") + assert qtmaildirconf.sent_folders(path) == [] + + +def test_comments_and_other_sections_are_ignored(): + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "; a comment\n" + "[general]\n" + "language = it\n" + "[sync]\n" + "command = /bin/true\n" + "[account.a]\n" + "; another comment\n" + "maildir = a\n" + "sent = Sent\n") + assert qtmaildirconf.sent_folders(path) == ["a/Sent"] + + +def test_a_missing_file_yields_no_folders(): + """The hook must run on a system with no qtmaildir config at all: it + then protects nothing, rather than failing the sync.""" + with tempfile.TemporaryDirectory() as tmp: + assert qtmaildirconf.sent_folders(Path(tmp) / "absent.conf") == [] + + +def test_an_unreadable_file_yields_no_folders(): + """A malformed config must not fail the sync. notmuch new has already + run at this point; refusing to tag is worse than not protecting sent + mail for one cycle.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "this is not an ini file\n[[[\n") + assert qtmaildirconf.sent_folders(path) == [] + + +def test_the_query_scopes_every_folder(): + folders = ["a/Sent", "g/[Gmail]/Posta inviata"] + query = qtmaildirconf.sent_query(folders) + assert query == ('path:"a/Sent/**" or path:"g/[Gmail]/Posta inviata/**"') + + +def test_the_query_is_empty_when_no_folder_is_configured(): + """An empty query means "match everything" to notmuch, so the caller + must be able to tell "nothing to protect" from "protect the world".""" + assert qtmaildirconf.sent_query([]) == "" + + +def test_a_folder_containing_a_quote_cannot_break_out_of_the_query(): + """The folder name reaches a notmuch query as a quoted string. A stray + double quote would end the term and let the rest be read as syntax.""" + query = qtmaildirconf.sent_query(['a/He said "hi"']) + assert query.count('"') % 2 == 0 + assert "\\\"" in query or '""' in query + + +def main(): + tests = [value for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value)] + for test in tests: + test() + print(f"ok {test.__name__}") + print(f"\n{len(tests)} passed") + + +if __name__ == "__main__": + main() |
