# SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie # SPDX-License-Identifier: MIT # This library against a real Dovecot, which is the implementation everyone # else's maildir is checked against: it is what most IMAP mail is served from, # and where the conventions this library follows -- the `,S=` size field, the # lowercase keyword letters, the `maildirfolder` marker -- actually come from. # # It needs a virtual machine rather than a fixture in `zig build test` because # the claim being tested is that a *second program* agrees, and that program # wants a system: users, a mail directory it owns, and a daemon that indexes # what it finds there. # # Everything here is driven through `doveadm` rather than over IMAP. It is the # same mail code with the protocol taken off, and it does not need a login, # which keeps the test about maildirs rather than about PAM. { zig-maildir }: { name = "zig-maildir-dovecot"; nodes.machine = { config, pkgs, ... }: { environment.systemPackages = [ zig-maildir ]; users.users.alice = { isNormalUser = true; uid = 1000; home = "/home/alice"; }; services.dovecot2 = { enable = true; # Without a password database the auth service refuses to start at # all -- "No passdbs specified in configuration file" -- and then even # `doveadm`, which authenticates nobody, cannot look a user up. enablePAM = true; settings = { # Dovecot 2.4 refuses to start without being told which version of # the configuration format the file is written in, and which version # of the on-disk format the mail should stay readable by. Pinned to # whatever nixpkgs ships, since the test is rebuilt with it. dovecot_config_version = config.services.dovecot2.package.version; dovecot_storage_version = config.services.dovecot2.package.version; protocols.imap = true; mail_driver = "maildir"; mail_home = "/home/%{user | username}"; mail_path = "~/Maildir"; # A slash between the levels of the hierarchy, so that the names # `doveadm` prints are the ones `zig-maildir` is given. Dovecot # still stores them the Maildir++ way, as `.Work.Reports`; the # separator is only how they are spelled in the protocol, which is # exactly the distinction `folder.writeName` exists for. "namespace inbox" = { inbox = true; separator = "/"; }; }; }; }; testScript = '' machine.wait_for_unit("dovecot.service") # -- this library writes, Dovecot reads --------------------------------- machine.succeed("su alice -c 'zig-maildir create /home/alice/Maildir'") machine.succeed( "su alice -c \"printf 'From: jeff@example.com\\r\\n" "To: alice@example.com\\r\\n" "Subject: Written by zig-maildir\\r\\n" "Message-ID: \\r\\n" "\\r\\nHello.\\r\\n' > /tmp/one.eml\"" ) machine.succeed("su alice -c 'zig-maildir deliver /home/alice/Maildir /tmp/one.eml'") # Dovecot finds the message this library delivered, and reads it as a # message rather than as a file: the subject comes out of its own index. status = machine.succeed("doveadm mailbox status -u alice messages INBOX") assert "messages=1" in status, status subjects = machine.succeed("doveadm fetch -u alice hdr mailbox INBOX all") assert "Written by zig-maildir" in subjects, subjects # The size this library wrote into the name is the size Dovecot reports, # which is the whole reason `,S=` is allowed to be trusted. on_disk = machine.succeed( "su alice -c 'zig-maildir list /home/alice/Maildir' | cut -f4" ).strip() reported = machine.succeed( "doveadm fetch -u alice size.physical mailbox INBOX all | tail -1" ).strip() assert on_disk in reported, "zig-maildir says " + on_disk + ", dovecot says " + reported # -- flags, in both directions ------------------------------------------ # Dovecot sets a flag; this library sees it. machine.succeed("doveadm flags add -u alice '\\Seen' mailbox INBOX all") listing = machine.succeed("su alice -c 'zig-maildir list /home/alice/Maildir'") assert "\tS\t" in listing, listing # And it is still in `new`, which is the whole reason this library parses # a name leniently rather than by the rule. The specification says a # message in `new` has no info field; Dovecot, which has an index of its # own and does not need the directory to tell it what is unread, renames # the file *in place* and leaves `new/...,S=121:2,S` sitting there. A # reader that took the rule literally would show that message as unread # forever. It also kept the `,S=` this library wrote, rather than # recomputing or dropping it. assert listing.split("\t")[0] == "new", listing names = machine.succeed("ls -1 /home/alice/Maildir/new") assert ":2,S" in names, names assert ",S=" in names, names assert machine.succeed("ls -1 /home/alice/Maildir/cur").strip() == "" # This library sets a flag; Dovecot sees it. message_id = listing.split("\t")[4].strip() machine.succeed( "su alice -c 'zig-maildir flag /home/alice/Maildir " + message_id + " +R +F'" ) flags = machine.succeed("doveadm fetch -u alice flags mailbox INBOX all") assert "\\Answered" in flags, flags assert "\\Flagged" in flags, flags assert "\\Seen" in flags, flags # -- the one that would lose somebody's data ----------------------------- # Dovecot stores an IMAP keyword as a lowercase letter in the name, with a # `dovecot-keywords` file mapping the letter to the label. A program that # read `:2,FRSa`, changed a flag and wrote back `:2,FRS` would have # silently deleted a label the user applied -- so this is the assertion # that the `other` letters are carried through untouched. machine.succeed("doveadm flags add -u alice 'Important' mailbox INBOX all") before = machine.succeed("su alice -c 'zig-maildir list /home/alice/Maildir'") assert "a" in before.split("\t")[2], before machine.succeed( "su alice -c 'zig-maildir flag /home/alice/Maildir " + message_id + " -F'" ) after = machine.succeed("doveadm fetch -u alice flags mailbox INBOX all") assert "Important" in after, after assert "\\Flagged" not in after, after # -- Maildir++ folders, in both directions ------------------------------- machine.succeed("su alice -c 'zig-maildir mkfolder /home/alice/Maildir Work/Reports'") mailboxes = machine.succeed("doveadm mailbox list -u alice") assert "Work" in mailboxes, mailboxes assert "Work/Reports" in mailboxes, mailboxes # On disk it is flat, which is what Maildir++ means by a hierarchy. machine.succeed("test -d /home/alice/Maildir/.Work.Reports/cur") machine.succeed("test -f /home/alice/Maildir/.Work.Reports/maildirfolder") # And a folder Dovecot made is one this library lists. machine.succeed("doveadm mailbox create -u alice Archive") folders = machine.succeed("su alice -c 'zig-maildir folders /home/alice/Maildir'") assert "Archive" in folders, folders assert "Work/Reports" in folders, folders # -- a message moved between folders ------------------------------------- machine.succeed( "su alice -c 'zig-maildir move /home/alice/Maildir " + message_id + " Archive'" ) inbox_count = machine.succeed("doveadm mailbox status -u alice messages INBOX") assert "messages=0" in inbox_count, inbox_count archive_count = machine.succeed("doveadm mailbox status -u alice messages Archive") assert "messages=1" in archive_count, archive_count # The standard flags travelled with it, even though the name did not: two # directories do not coordinate their names, so a moved message # necessarily gets a new one. moved = machine.succeed("doveadm fetch -u alice flags mailbox Archive all") assert "\\Seen" in moved, moved assert "\\Answered" in moved, moved # The keyword did not, and this is the sharp edge worth pinning down: a # keyword's *letter* lives in the file name, but what that letter means # lives in a `dovecot-keywords` file inside each mailbox. The letter is # carried across by the rename; the mapping is not, so Dovecot reads it in # Archive as a keyword it has never heard of. `Message.moveTo` says so, and # there is nothing this library can do about it -- that mapping file is # Dovecot's, not the maildir's. assert "unknown-" in moved, moved assert "Important" not in moved, moved # -- Dovecot delivers, this library reads -------------------------------- machine.succeed( "printf 'From: someone@example.net\\r\\n" "To: alice@example.com\\r\\n" "Subject: Written by dovecot\\r\\n" "\\r\\nBody.\\r\\n' | doveadm save -u alice -m INBOX" ) listing = machine.succeed("su alice -c 'zig-maildir list /home/alice/Maildir'") assert "INBOX" in listing, listing saved_id = [ line.split("\t")[4] for line in listing.strip().split("\n") if line.split("\t")[1] == "INBOX" ][0] headers = machine.succeed( "su alice -c 'zig-maildir headers /home/alice/Maildir " + saved_id + "'" ) assert "Written by dovecot" in headers, headers assert "someone@example.net" in headers, headers # -- the quota ledger ---------------------------------------------------- machine.succeed("su alice -c 'zig-maildir quota /home/alice/Maildir 10485760 1000'") machine.succeed("su alice -c 'zig-maildir recalc /home/alice/Maildir'") quota = machine.succeed("su alice -c 'zig-maildir quota /home/alice/Maildir'") assert "2 messages" in quota, quota assert "over\tFalse" in quota or "over\tfalse" in quota, quota # `maildirsize` is Courier's file, and Dovecot leaves a file it does not # use alone rather than deleting it -- which is what makes it safe to keep # one in a store Dovecot is also serving. machine.succeed("test -f /home/alice/Maildir/maildirsize") ''; }