aboutsummaryrefslogtreecommitdiffstats
path: root/assets/hooks/test_qtmaildirconf.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-23 21:15:13 +0200
committerDanilo M. <danix@danix.xyz>2026-08-23 21:15:13 +0200
commitfabcf080652c6e5d57bf234be5e100769a9b965b (patch)
tree0de4222c1e2aab58c38d34c9e0e3c37c68298cc8 /assets/hooks/test_qtmaildirconf.py
parentc50bea78e036518ce1a2a3eb899bbb5e305affea (diff)
parentddcae8d02ef46db522b3cf6c228196c7a66a6432 (diff)
downloadqtmaildir-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-xassets/hooks/test_qtmaildirconf.py179
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()