A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-maildir / tests / nixos / dovecot.nix
10 kB 225 lines
1# SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2# SPDX-License-Identifier: MIT 3 4# This library against a real Dovecot, which is the implementation everyone 5# else's maildir is checked against: it is what most IMAP mail is served from, 6# and where the conventions this library follows -- the `,S=` size field, the 7# lowercase keyword letters, the `maildirfolder` marker -- actually come from. 8# 9# It needs a virtual machine rather than a fixture in `zig build test` because 10# the claim being tested is that a *second program* agrees, and that program 11# wants a system: users, a mail directory it owns, and a daemon that indexes 12# what it finds there. 13# 14# Everything here is driven through `doveadm` rather than over IMAP. It is the 15# same mail code with the protocol taken off, and it does not need a login, 16# which keeps the test about maildirs rather than about PAM. 17 18{ zig-maildir }: 19 20{ 21 name = "zig-maildir-dovecot"; 22 23 nodes.machine = 24 { config, pkgs, ... }: 25 { 26 environment.systemPackages = [ zig-maildir ]; 27 28 users.users.alice = { 29 isNormalUser = true; 30 uid = 1000; 31 home = "/home/alice"; 32 }; 33 34 services.dovecot2 = { 35 enable = true; 36 # Without a password database the auth service refuses to start at 37 # all -- "No passdbs specified in configuration file" -- and then even 38 # `doveadm`, which authenticates nobody, cannot look a user up. 39 enablePAM = true; 40 settings = { 41 # Dovecot 2.4 refuses to start without being told which version of 42 # the configuration format the file is written in, and which version 43 # of the on-disk format the mail should stay readable by. Pinned to 44 # whatever nixpkgs ships, since the test is rebuilt with it. 45 dovecot_config_version = config.services.dovecot2.package.version; 46 dovecot_storage_version = config.services.dovecot2.package.version; 47 48 protocols.imap = true; 49 mail_driver = "maildir"; 50 mail_home = "/home/%{user | username}"; 51 mail_path = "~/Maildir"; 52 # A slash between the levels of the hierarchy, so that the names 53 # `doveadm` prints are the ones `zig-maildir` is given. Dovecot 54 # still stores them the Maildir++ way, as `.Work.Reports`; the 55 # separator is only how they are spelled in the protocol, which is 56 # exactly the distinction `folder.writeName` exists for. 57 "namespace inbox" = { 58 inbox = true; 59 separator = "/"; 60 }; 61 }; 62 }; 63 }; 64 65 testScript = '' 66 machine.wait_for_unit("dovecot.service") 67 68 # -- this library writes, Dovecot reads --------------------------------- 69 70 machine.succeed("su alice -c 'zig-maildir create /home/alice/Maildir'") 71 machine.succeed( 72 "su alice -c \"printf 'From: jeff@example.com\\r\\n" 73 "To: alice@example.com\\r\\n" 74 "Subject: Written by zig-maildir\\r\\n" 75 "Message-ID: <one@example.com>\\r\\n" 76 "\\r\\nHello.\\r\\n' > /tmp/one.eml\"" 77 ) 78 machine.succeed("su alice -c 'zig-maildir deliver /home/alice/Maildir /tmp/one.eml'") 79 80 # Dovecot finds the message this library delivered, and reads it as a 81 # message rather than as a file: the subject comes out of its own index. 82 status = machine.succeed("doveadm mailbox status -u alice messages INBOX") 83 assert "messages=1" in status, status 84 subjects = machine.succeed("doveadm fetch -u alice hdr mailbox INBOX all") 85 assert "Written by zig-maildir" in subjects, subjects 86 87 # The size this library wrote into the name is the size Dovecot reports, 88 # which is the whole reason `,S=` is allowed to be trusted. 89 on_disk = machine.succeed( 90 "su alice -c 'zig-maildir list /home/alice/Maildir' | cut -f4" 91 ).strip() 92 reported = machine.succeed( 93 "doveadm fetch -u alice size.physical mailbox INBOX all | tail -1" 94 ).strip() 95 assert on_disk in reported, "zig-maildir says " + on_disk + ", dovecot says " + reported 96 97 # -- flags, in both directions ------------------------------------------ 98 99 # Dovecot sets a flag; this library sees it. 100 machine.succeed("doveadm flags add -u alice '\\Seen' mailbox INBOX all") 101 listing = machine.succeed("su alice -c 'zig-maildir list /home/alice/Maildir'") 102 assert "\tS\t" in listing, listing 103 104 # And it is still in `new`, which is the whole reason this library parses 105 # a name leniently rather than by the rule. The specification says a 106 # message in `new` has no info field; Dovecot, which has an index of its 107 # own and does not need the directory to tell it what is unread, renames 108 # the file *in place* and leaves `new/...,S=121:2,S` sitting there. A 109 # reader that took the rule literally would show that message as unread 110 # forever. It also kept the `,S=` this library wrote, rather than 111 # recomputing or dropping it. 112 assert listing.split("\t")[0] == "new", listing 113 names = machine.succeed("ls -1 /home/alice/Maildir/new") 114 assert ":2,S" in names, names 115 assert ",S=" in names, names 116 assert machine.succeed("ls -1 /home/alice/Maildir/cur").strip() == "" 117 118 # This library sets a flag; Dovecot sees it. 119 message_id = listing.split("\t")[4].strip() 120 machine.succeed( 121 "su alice -c 'zig-maildir flag /home/alice/Maildir " + message_id + " +R +F'" 122 ) 123 flags = machine.succeed("doveadm fetch -u alice flags mailbox INBOX all") 124 assert "\\Answered" in flags, flags 125 assert "\\Flagged" in flags, flags 126 assert "\\Seen" in flags, flags 127 128 # -- the one that would lose somebody's data ----------------------------- 129 130 # Dovecot stores an IMAP keyword as a lowercase letter in the name, with a 131 # `dovecot-keywords` file mapping the letter to the label. A program that 132 # read `:2,FRSa`, changed a flag and wrote back `:2,FRS` would have 133 # silently deleted a label the user applied -- so this is the assertion 134 # that the `other` letters are carried through untouched. 135 machine.succeed("doveadm flags add -u alice 'Important' mailbox INBOX all") 136 before = machine.succeed("su alice -c 'zig-maildir list /home/alice/Maildir'") 137 assert "a" in before.split("\t")[2], before 138 139 machine.succeed( 140 "su alice -c 'zig-maildir flag /home/alice/Maildir " + message_id + " -F'" 141 ) 142 after = machine.succeed("doveadm fetch -u alice flags mailbox INBOX all") 143 assert "Important" in after, after 144 assert "\\Flagged" not in after, after 145 146 # -- Maildir++ folders, in both directions ------------------------------- 147 148 machine.succeed("su alice -c 'zig-maildir mkfolder /home/alice/Maildir Work/Reports'") 149 mailboxes = machine.succeed("doveadm mailbox list -u alice") 150 assert "Work" in mailboxes, mailboxes 151 assert "Work/Reports" in mailboxes, mailboxes 152 153 # On disk it is flat, which is what Maildir++ means by a hierarchy. 154 machine.succeed("test -d /home/alice/Maildir/.Work.Reports/cur") 155 machine.succeed("test -f /home/alice/Maildir/.Work.Reports/maildirfolder") 156 157 # And a folder Dovecot made is one this library lists. 158 machine.succeed("doveadm mailbox create -u alice Archive") 159 folders = machine.succeed("su alice -c 'zig-maildir folders /home/alice/Maildir'") 160 assert "Archive" in folders, folders 161 assert "Work/Reports" in folders, folders 162 163 # -- a message moved between folders ------------------------------------- 164 165 machine.succeed( 166 "su alice -c 'zig-maildir move /home/alice/Maildir " + message_id + " Archive'" 167 ) 168 inbox_count = machine.succeed("doveadm mailbox status -u alice messages INBOX") 169 assert "messages=0" in inbox_count, inbox_count 170 archive_count = machine.succeed("doveadm mailbox status -u alice messages Archive") 171 assert "messages=1" in archive_count, archive_count 172 173 # The standard flags travelled with it, even though the name did not: two 174 # directories do not coordinate their names, so a moved message 175 # necessarily gets a new one. 176 moved = machine.succeed("doveadm fetch -u alice flags mailbox Archive all") 177 assert "\\Seen" in moved, moved 178 assert "\\Answered" in moved, moved 179 180 # The keyword did not, and this is the sharp edge worth pinning down: a 181 # keyword's *letter* lives in the file name, but what that letter means 182 # lives in a `dovecot-keywords` file inside each mailbox. The letter is 183 # carried across by the rename; the mapping is not, so Dovecot reads it in 184 # Archive as a keyword it has never heard of. `Message.moveTo` says so, and 185 # there is nothing this library can do about it -- that mapping file is 186 # Dovecot's, not the maildir's. 187 assert "unknown-" in moved, moved 188 assert "Important" not in moved, moved 189 190 # -- Dovecot delivers, this library reads -------------------------------- 191 192 machine.succeed( 193 "printf 'From: someone@example.net\\r\\n" 194 "To: alice@example.com\\r\\n" 195 "Subject: Written by dovecot\\r\\n" 196 "\\r\\nBody.\\r\\n' | doveadm save -u alice -m INBOX" 197 ) 198 listing = machine.succeed("su alice -c 'zig-maildir list /home/alice/Maildir'") 199 assert "INBOX" in listing, listing 200 201 saved_id = [ 202 line.split("\t")[4] 203 for line in listing.strip().split("\n") 204 if line.split("\t")[1] == "INBOX" 205 ][0] 206 headers = machine.succeed( 207 "su alice -c 'zig-maildir headers /home/alice/Maildir " + saved_id + "'" 208 ) 209 assert "Written by dovecot" in headers, headers 210 assert "someone@example.net" in headers, headers 211 212 # -- the quota ledger ---------------------------------------------------- 213 214 machine.succeed("su alice -c 'zig-maildir quota /home/alice/Maildir 10485760 1000'") 215 machine.succeed("su alice -c 'zig-maildir recalc /home/alice/Maildir'") 216 quota = machine.succeed("su alice -c 'zig-maildir quota /home/alice/Maildir'") 217 assert "2 messages" in quota, quota 218 assert "over\tFalse" in quota or "over\tfalse" in quota, quota 219 220 # `maildirsize` is Courier's file, and Dovecot leaves a file it does not 221 # use alone rather than deleting it -- which is what makes it safe to keep 222 # one in a store Dovecot is also serving. 223 machine.succeed("test -f /home/alice/Maildir/maildirsize") 224 ''; 225}