A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
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}