Compare commits

...

517 Commits
v0.0.9 ... main

Author SHA1 Message Date
Mechiel Lukkien
833a67fe3d add config option to disable tls client auth during tls handshakes
to work around clients, like the gmail smtp client, that tries to
authenticate with a webpki-issued certificate (which we don't know).

i tried specifying a list of accepted (subjects of) CA certs during the
tls handshake (with just 1 entry, with "xmox.nl" as common name), which
clients can use to influence their cert selection.  however, the gmail
smtp client ignores it, so not a solution for the issue where this was
raised. also, specifying a list of accepted certs could cause other
clients to not send their client cert anymore, breaking existing setups.

i also considered only asking for tls client auth when at least one
account has a tls pubkey configured. but decided against it since any
account can add one on their own (without system admin interaction),
changing behaviour of the system and potentially breaking existing
submission/tls configs.

we now also print the "subject" and "issuer" of certs when tls client
auth fails, should be useful for future debugging.

for issue #359
2025-06-09 12:33:10 +02:00
Mechiel Lukkien
f5b8c64b84 fix typo's 2025-06-09 10:00:53 +02:00
Mechiel Lukkien
f1259ee80e add config option to disable rate limiting for the webserver, and take a reverse proxy into account when finding the ip to use for webserver ratelimting
another approach i looked at was enabling/disabling rate limiting per
web handler. but we want to apply the rate limit as early as possible
(not after we've already done quite some work for the request), and with
per-handler rate limits on/off the code would be sprinkled with calls to
rate limiting. this is probably good enough for now. other protocols are
less likely to need this.

we were always using the ip address of the connection for rate limiting.
but some setups have a reverse proxy in front. if any handler on a
http/https port is marked as "forwarded" (with a reverse proxy), we use
the ip address from the x-forwarded-for header (like we already did for
authentication requests over http).

for issue #346
2025-05-15 20:16:49 +02:00
Mechiel Lukkien
bb438488c5 add "Fail" transport, that immediately fails delivery
allows configs that prevent outgoing deliveries (globally, per domain,
or per account) from/to certain domains.

for issue #347
2025-05-15 17:59:49 +02:00
Mechiel Lukkien
91bfff220e add mx preference to smtpclient.GatherDestinations
mostly so moxtools can show the mx preferences in its output
2025-05-15 16:37:53 +02:00
Mechiel Lukkien
cc627af263 fix tests for previous commit
the build for go1.23 generated a different doc.go...
2025-05-15 14:42:07 +02:00
Mechiel Lukkien
76e58f4a63 fix building previous commit with go1.23 2025-05-15 14:25:18 +02:00
Mechiel Lukkien
4a14abc254 add "mox smtp dial" subcommand, for debugging connectivity issues
with various options for the tls connection.
2025-05-15 14:12:08 +02:00
Mechiel Lukkien
70bbfc8f10 fix findings from gopls 2025-05-13 09:33:37 +02:00
Mechiel Lukkien
aff279711c add ":z" to docker-compose volumes and remove deprecated version field from yml files
the ":z" is required on selinux systems, like fedora. and doesn't seem
to hurt on other systems.
2025-05-12 10:29:22 +02:00
Alice
2e0eea88b0
fix typos in referrer header
It should be referer in both cases, which whilst misspelled is the actual header name
2025-04-25 19:16:32 +01:00
Mechiel Lukkien
baacdbca18
when registering login attempts, use X-Forwarded-For header for finding the IP address
Which we already did for the rate limiting.

Hopefully solves issue #338.
2025-04-22 09:05:34 +02:00
Mechiel Lukkien
ee99e82cf4
add v0.0.15 to website and rotate apidiff 2025-04-18 21:25:37 +02:00
Mechiel Lukkien
b7262d536d
nit, tweaking release process order 2025-04-18 20:53:28 +02:00
Mechiel Lukkien
794ef75d17
accept incoming DMARC and TLS reports with reporting addresses containing catchall separator(s)
Such as "-" when addresses are dmarc-reports@ and tls-reports@.

Existing configuration files can have these combinations. We don't allow them
to be created through the webadmin interface, as this is a likely source of
confusion about how addresses will be matched. We already didn't allow regular
addresses containing catchall separators.
2025-04-18 12:36:01 +02:00
Mechiel Lukkien
4eddf5885d
change default dmarc & tls reporting address so they don't contain a dash
The defaults for a new domain were dmarc-reports@ and tls-reports@. But some
setups use "-" as catchall separator, which currently would cause messages to
those addresses to be rejected with a "no such user" smtp error.

Better to prevent these issues in the future by using dmarcreports@ and
tlsreports@ localparts.

The config checks don't enforce that the DMARC and TLS reporting addresses
don't contain the localpart catchall separator. A next commit will fix
accepting incoming reports to such addresses.
2025-04-18 11:39:45 +02:00
Mechiel Lukkien
53f391ad18
fix flaky test where closing the imapclient connection fails because the server has also closed the tls connection 2025-04-18 09:23:30 +02:00
Mechiel Lukkien
14af5bbb12
when reparsing all messages, actually store the new mime structure in the database 2025-04-18 09:05:09 +02:00
Mechiel Lukkien
75bb1bfa2f
queue: before removing files from the queue, close them, so removing doesn't fail on windows
Mostly relevant for localserve, since full operation doesn't work on windows.
2025-04-17 21:08:07 +02:00
Mechiel Lukkien
5f9f45983d
use smaller batch size when reparsing all messages, to stay response when making changes on slower machines 2025-04-17 09:47:53 +02:00
Mechiel Lukkien
0ce0296a9f
update public suffix list 2025-04-16 20:09:11 +02:00
Mechiel Lukkien
805ae0d827
update to latest golang.org/x dependencies 2025-04-16 20:06:58 +02:00
Mechiel Lukkien
1b2b152cb5
add "mox config account list", printing all accounts and whether they are disabled
based on question from wisse on slack
2025-04-16 20:06:58 +02:00
Mechiel Lukkien
31c22618f5
automatically reparse all messages, in the background, after addition of header fields in the parsed mime form of messages in the message index database
With that recent change, we would keep track of Content-* headers of parsed
messages. We could ask admins to run a command to reparse messages for all
accounts. But instead we just do it automatically when opening the account. We
keep track whether we did the upgrade. And we do it in the background. Those
recent changes were to add optional fields to the IMAP fetch "bodystructure"
responses. There is a small chance that an IMAP client requests these fields
before they are properly populated with the reparse (only existing messages,
new incoming messages are parsed with the new code). We could try to detect
whether the upgrade has completed, and chance IMAP behaviour based on that. But
the complexity and long-term maintenance burden doesn't seem worth it. Worst
case, we'll temporarily claim some relatively unimportant headers aren't
present on a message. Most email clients won't even look at those fields, but
will parse them message themselves instead.
2025-04-16 20:06:58 +02:00
Mechiel Lukkien
07533252b3
message: when parsing a message, don't treat absent header and empty header value the same
We now use "*string" for such header fields, for Content-* fields, as used in
the imapserver when responding to FETCH commands. We'll now return NIL for an
absent header, and "" (empty string) if the header value is empty.
2025-04-16 20:06:45 +02:00
Mechiel Lukkien
3fe765dce9
imapserver: fix fuzz tests
The acc.Close() at the end of the fuzzing would find inconsistencies. For
example, message files on disk that aren't in the database file. I don't
understand what is happening there, the database file on disk does have those
messages, and it seems the database file is getting replaced. When running the
same code not as a fuzzing test but as a regular Go test doesn't show the
problem. So it seems to be some interaction with fuzzing. The problem is
"solved" (feels more like side-stepped), by starting each fuzz test with a
clean database. We still open & close the account in each fuzz test, and it
doesn't find consistency problems.
2025-04-16 11:21:01 +02:00
Mechiel Lukkien
e7b562e3f2
imapclient: first step towards making package usable as imap client with other imap servers, and minor imapserver bug fix
The imapclient needs more changes, like more strict parsing, before it can be a
generally usable IMAP client, these are a few steps towards that.

- Fix a bug in the imapserver METADATA responses for TOOMANY and MAXSIZE.
- Split low-level IMAP protocol handling (new Proto type) from the higher-level
  client command handling (existing Conn type). The idea is that some simple
  uses of IMAP can get by with just using these commands, while more intricate
  uses of IMAP (like a synchronizing client that needs to talk to all kinds of
  servers with different behaviours and implemented extensions) can write custom
  commands and read untagged responses or command completion results
  explicitly. The lower-level method names have clearer names now, like
  ReadResponse instead of Response.
- Merge the untagged responses and (command completion) "Result" into a new
  type Response. Makes function signatures simpler. And make Response implement
  the error interface, and change command methods to return the Response as error
  if the result is NO or BAD. Simplifies error handling, and still provides the
  option to continue after a NO or BAD.
- Add UIDSearch/MSNSearch commands, with a custom "search program", so mostly
  to indicate these commands exist.
- More complete coverage of types for response codes, for easier handling.
- Automatically handle any ENABLED or CAPABILITY untagged response or response
  code for IMAP command methods on type Conn.
- Make difference between MSN vs UID versions of
  FETCH/STORE/SEARCH/COPY/MOVE/REPLACE commands more clear. The original MSN
  commands now have MSN prefixed to their name, so they are grouped together in
  the documentation.
- Document which capabilities are needed for a command.
2025-04-15 08:37:18 +02:00
Mechiel Lukkien
2c1283f032
imapclient: clean up function signature of New, allowing for future options too 2025-04-11 21:04:13 +02:00
Mechiel Lukkien
af3e9351bc
imapserver: simplify and fix logic around processing changes while opening a mailbox (with SELECT or EXAMINE)
We were first getting UIDs in a transaction with a lock. Then getting the
changes and processing them in a special way. And then processing for qresync
in a new transaction. The special processing of changes is now gone, it seems
to have skipped adding/removing uids to the session, which can't be correct.
The new approach is just using a lock and transaction and process the whole
opening of the mailbox, and not processing any changes as part of the open, and
getting rid of the special "initial" mode processing a mailbox.
2025-04-11 20:28:35 +02:00
Mechiel Lukkien
fd5167fdb3
imapserver: enable test that checked that an expunged message can still be read in sessions when they haven't processed the deletion yet.
We've been keeping track of references before we erase the message file for a
while now.
2025-04-11 18:27:42 +02:00
Mechiel Lukkien
1a6d268e1d
imapserver: check for UIDNEXT overflow when adding a message to a mailbox
Return an error, with instructions so a user may be able to work around the
issue.
2025-04-11 18:22:29 +02:00
Mechiel Lukkien
507ca73b96
imapserver: implement UIDONLY extension, RFC 9586
Once clients enable this extension, commands can no longer refer to "message
sequence numbers" (MSNs), but can only refer to messages with UIDs. This means
both sides no longer have to carefully keep their sequence numbers in sync
(error-prone), and don't have to keep track of a mapping of sequence numbers to
UIDs (saves resources).

With UIDONLY enabled, all FETCH responses are replaced with UIDFETCH response.
2025-04-11 11:45:49 +02:00
Mechiel Lukkien
8bab38eac4
imapserver: implement NOTIFY extension from RFC 5465
NOTIFY is like IDLE, but where IDLE watches just the selected mailbox, NOTIFY
can watch all mailboxes. With NOTIFY, a client can also ask a server to
immediately return configurable fetch attributes for new messages, e.g. a
message preview, certain header fields, or simply the entire message.

Mild testing with evolution and fairemail.
2025-04-11 10:06:34 +02:00
Mechiel Lukkien
5a7d5fce98
run ineffassign (fast) before staticcheck (slow) 2025-04-07 18:40:54 +02:00
Mechiel Lukkien
902de0e1f9
queue: in log lines about delivery, we had both "attempts" starting at 0 and "attempt" starting at 1, keep only "attempts" starting at 1
from eric l, thanks!
2025-04-07 13:35:42 +02:00
Mechiel Lukkien
39c21f80cd
imapserver: return proper response for FETCH of "BODY[1.MIME]" where 1 is a message
MIME returns the part headers. If 1 is a message, i.e. a message/rfc822 or
message/global, for example when top-level is a multipart/mixed, we were
returning the MIME headers from the message, not from the part.

We also shouldn't be returning a MIME-Version header or the separating newline
for MIME. Those are for MIME headers of a message, but the "MIME" fetch body
part is always about the part.

Found after looking into FETCH BODY handling for issue #327.
2025-04-07 12:15:13 +02:00
Mechiel Lukkien
462568d878
webmail: for "cid"/content-id's used in html, look for them in all other parts, not just when there is a multipart/related in the message
The gmail apps generate messages consisting of multipart/mixed, with text/html
referring to a sibling image/jpeg. We weren't resolving that cid before.

Related to issue #327.
2025-04-07 11:10:14 +02:00
Mechiel Lukkien
2defbce0bc
imapserver: return all the extensible fields for bodystructure, notably for content-disposition
The gmail iOS/Android app were showing mime image parts as (garbled) text
instead of rendering them as image. By returning all the optional fields in the
bodystructure fetch attribute, the gmail app renders the image as expected by
the user. So we now add all fields. We didn't before, because we weren't
keeping track of Content-MD5, Content-Language and Content-Location header
fields, since they aren't that useful.

Messages in mailboxes have to be reparsed:
	./mox reparse

Without reparsing, imap responses will claim the extra fields
(content-disposition) are absent for existing messages, instead of not claiming
anything at all, which is what we did before.

Accounts and all/some mailboxes can get their "uid validity" bumped ("./mox
bumpuidvalidity $account [$mailbox]"), which should trigger clients to load all
messages from scratch, but gmail doesn't appear to notice, so it would be
better to remove & add the account in gmail.

For issue #327, also relevant to issue #217.
2025-04-05 15:46:17 +02:00
Mechiel Lukkien
69d2699961
write base64 message parts with 76 data bytes on a line instead of 78
As required by RFC 2045 (MIME). The 78 byte lines work in practice, except that
SpamAssassin has rules that give messages with 78-byte lines spam points.

Mentioned by kjetilho on irc.
2025-04-03 10:22:15 +02:00
Mechiel Lukkien
00c8db98e6
start more function names/calls with x when they handle errors through panics
mostly the imapserver and smtpserver connection write and read methods.
2025-04-02 13:59:46 +02:00
Mechiel Lukkien
deb57462a4
update list of sponsors, add logo's and link to the nlnet projects 2025-04-02 11:24:59 +02:00
Mechiel Lukkien
479bf29124
imapserver: implement the MULTISEARCH extension, with its ESEARCH command 2025-03-31 18:34:23 +02:00
Mechiel Lukkien
5dcf674761
webmail: reconnect automatically in more cases
Before, we would only reconnect the SSE connection when the previous one lasted
10 minutes.  For some reason, firefox disconnects SSE connections when there is
any network change. Running the docker integration tests changes the network a
few time in quick succession, prevent further automatic reconnects.

This changes the "stop reconnection automatically" period from 10 minutes to 5
seconds.
2025-03-30 14:54:29 +02:00
Mechiel Lukkien
aba0061073
small tweak to docs and website, mentioning EIA in the context of internalized email 2025-03-30 11:03:06 +02:00
Mechiel Lukkien
cc5e3165ea
imapserver: implement "inprogress" response code (RFC 9585) for keepalive during long search
For long searches in big mailboxes, without any matches, we would previously
keep working and not say anything. Clients could interpret this silence as a
broken connection at some point. We now send a "we're still searching" untagged
OK responses with code INPROGRESS every 10 seconds while we're still searching,
to prevent the client from closing the connection. We also send how many
messages we've processed, and usually also how many we need to process in grand
total. Clients can use this to show a progress bar.
2025-03-30 10:43:02 +02:00
Mechiel Lukkien
3e128d744e
for the web interfaces, ensure the effective configured http paths end in a slash to prevent 404's and/or errors accessing the web interfaces
The default paths for the web interfaces, such as /admin/, /account/, /webmail/
and /webapi/ end with a slash. They should end with a slash because we use the
path when restricting cookies to just that web interface. You could configure
paths not ending with a slash, but due to using http.StripPrefix, and our
handler, some of those requests may not work properly.

We now warn if configured paths don't end with a trailing slash when parsing
the config file. We normally error out when such things happen, but users
probably have paths without trailing slashes configured, and we don't want to
break them on a future upgrade. We now use an effective path that includes the
trailing slash.

We would always redirect requests to the configured paths but without trailing
slash to the path with trailing slash, and that stays.

For issue #325 by odama626.
2025-03-29 22:00:55 +01:00
Mechiel Lukkien
3a3a11560e
web interfaces: don't include version number in html, only return it after authentication
second round for issue #322
2025-03-29 20:46:53 +01:00
Mechiel Lukkien
eeeabdc6de
fix build with previous commit that didn't sync frontend
not at my sharpest...
2025-03-29 20:16:05 +01:00
Mechiel Lukkien
3ac38aacca
imapserver: fix storing previews when requested over imap and they are missing from the database
found while testing.
2025-03-29 20:13:10 +01:00
Mechiel Lukkien
6ab31c15b7
imapserver: actually announce PREVIEW extension 2025-03-29 18:28:33 +01:00
Mechiel Lukkien
a5d74eb718
webmail: add buttons to download a message as eml, and export 1 or more messages as mbox/maildir in zip/tgz/tar, like for entire mailboxes
Download as eml is useful with firefox, because opening the raw message in a
new tab, and then downloading it, causes firefox to request the url without
cookies, causing it to save a "403 - forbidden" response.

Exporting a selection is useful during all kinds of testing. Makes it easy to
an entire thread, or just some messages.

The export popover now has buttons for each combination of mbox/maildir vs
zip/tgz/tar. Before you may have had to select the email format and archive
format first, followed by a click. Now it's just a click.
2025-03-29 18:10:23 +01:00
Mechiel Lukkien
d6e55b5f36
don't use strings.Lines, it's only available in go1.24 and we support go1.23 too 2025-03-28 18:20:18 +01:00
Mechiel Lukkien
68729fa5a3
in smtp banner and imap ID command response when unauthenticated, don't send the mox version number
Attackers scanning the internet can use it to easily create a database of
hosts, software and versions. Let's not make it too easy to find old versions
that may be vulnerable to potential bugs found in the future. We could try
hiding the name "mox" as well, but the banner will still be identifyable, so
there isn't much point, and the public knowing approximately which software is
running can be useful for debugging.

The ID command in IMAP is used by clients to announce their software and
version. We only respond with our version when the user is authenticated.

There are still ways to discover the version number. But they don't involve
standard banner scanning, so someone would have to specifically target mox. We
could tighten that in the future.

For issue #322, based on email. Thanks everyone for discussing.
2025-03-28 17:50:40 +01:00
Mechiel Lukkien
789e4875ca
update to latest bstore 2025-03-28 17:39:20 +01:00
Mechiel Lukkien
6bf80d91bc
sync frontend api doc/client
Forgot to build after change just before commit...
2025-03-28 17:39:20 +01:00
Mechiel Lukkien
aa631c604c
imapserver: implement PREVIEW extension (RFC 8970), and store previews in message database
We were already generating previews of plain text parts for the webmail
interface, but we didn't store them, so were generating the previews each time
messages were listed.

Now we store previews in the database for faster handling. And we also generate
previews for html parts if needed. We use the first part that has textual
content.

For IMAP, the previews can be requested by an IMAP client. When we get the
"LAZY" variant, which doesn't require us to generate a preview, we generate it
anyway, because it should be fast enough. So don't make clients first ask for
"PREVIEW (LAZY)" and then again a request for "PREVIEW".

We now also generate a preview when a message is added to the account. Except
for imports. It would slow us down, the previews aren't urgent, and they will
be generated on-demand at first-request.
2025-03-28 17:10:17 +01:00
Mechiel Lukkien
8b418a9ca2
update golang.org/x dependencies 2025-03-28 17:01:12 +01:00
Mechiel Lukkien
027e5754a0
update to go1.23 and replace golang.org/x/exp/maps with stdlib maps 2025-03-28 17:01:06 +01:00
Mechiel Lukkien
7a87522be0
rename variables, struct fields and functions to include an "x" when they can panic for handling errors
and document the convention in develop.txt.
spurred by running errcheck again (it has been a while). it still has too many
false to enable by default.
2025-03-24 16:12:22 +01:00
Mechiel Lukkien
a2c79e25c1
check and log errors more often in deferred cleanup calls, and log remote-induced errors at lower priority
We normally check errors for all operations. But for some cleanup calls, eg
"defer file.Close()", we didn't. Now we also check and log most of those.
Partially because those errors can point to some mishandling or unexpected code
paths (eg file unexpected already closed). And in part to make it easier to use
"errcheck" to find the real missing error checks, there is too much noise now.

The log.Check function can now be used unconditionally for checking and logging
about errors. It adjusts the log level if the error is caused by a network
connection being closed, or a context is canceled or its deadline reached, or a
socket deadline is reached.
2025-03-24 14:06:05 +01:00
Mechiel Lukkien
15a8ce8c0b
fix warnings by ineffassign, with a one actual issue
In store/search.go, we would make a copy of a byte array, but then still use
the original instead of the copy. Could result in search operations not finding
messages that do have the content, but under very unlikely conditions only.

We'll keep running ineffassign with "make check", useful enough.
2025-03-24 10:25:33 +01:00
Mechiel Lukkien
04b1f030b7
update to latest bstore, which now properly handles modifications during Query.ForEach 2025-03-24 10:02:50 +01:00
Mechiel Lukkien
88ec5c6fbe
add rfc 4155 about mbox files, and cross-reference in the import/export code for mbox files 2025-03-23 13:59:09 +01:00
Mechiel Lukkien
a68a9d8a48
check whether mailboxes have message/etc counts through an "upgrade" boolean flag
Instead of using the per-mailbox flag, and going through all mailboxes when
opening an account.
2025-03-23 12:52:59 +01:00
Mechiel Lukkien
b37faa06bd
After queueing a message in the web api's, prevent context cancelation from completing message changes
Adding to the queue is done in a transaction, the queue db file is mox-global.
Appending the message to the Sent folder, removing it from Drafts, marking the
original message as answered/forwarded, is done in a separate database
transaction that gets the ctx passed in. If the ctx was canceled in between,
the queueing was finished, but the rest wasn't completed.

Reported by mteege, thanks!
2025-03-23 11:07:39 +01:00
Mechiel Lukkien
b0e4dcdb61
sync to latest autocert 2025-03-21 21:47:59 +01:00
Mechiel Lukkien
773d8cc959
update to latest github.com/mjl-/adns, synced to go1.24.1 2025-03-21 18:42:02 +01:00
Mechiel Lukkien
70aedddc90
webmail: when composing, no longer remove the last remaining To address with the ctrl+backspace shortcut
On reply, with too many Cc/Bcc, I usually hit ctrl+backspace a few time. I just
want to clear the addresses, but I practically always still want a To address.
2025-03-21 13:51:53 +01:00
Mechiel Lukkien
297e83188c
Check for queued messages when removing an address, and more completely cleanup accounts when removing.
When removing an address, we want to make sure any queued messages for the
account still still have their address associated with the account. E.g.
through a catchall address.

Before removing an account, we fail deliveries still in the queue for the
account. We remove any addresses on the suppression list (which are stored in
the queue database, not the account database file that is removed completely).
We also clear all sessions for the webmail/webaccount interfaces. For the
webmail, further operations will fail, and the reconnection attempt will cause
the login popup with a message about an unknown session token.
2025-03-21 13:36:10 +01:00
Mechiel Lukkien
75036c3a71
Before moving message files in imapserver and webmail API, ensure the message destination directory for the newly assigned IDs exist.
Example symptom, when deleting a message in the webmail (which moves to Trash):

        l=error m="duplicating message in old mailbox for current sessions" err="link data/accounts/mjl/msg/I/368638 data/accounts/mjl/msg/J/368640: no such file or directory" pkg=webmail

Problem introduced a few weeks ago, where moving messages starting duplicating
the message first, and the copy is erased once all references (in IMAP
sessions) to the old mailbox have been removed.
2025-03-21 10:18:39 +01:00
Mechiel Lukkien
99f9eb438f
Minor cleanup: use the ModSeq from the Mailbox in a ChangeMailboxAdd, no need to add the ModSeq again 2025-03-20 00:10:47 +01:00
Mechiel Lukkien
9ca50ab207
imapserver: When trying to replace a message in a non-existent mailbox, do still consume the message if it is a non-synchronized literal
Not likely to happen in the wild.
2025-03-19 22:00:34 +01:00
Mechiel Lukkien
5294a63c26
When logging structs, do log fields of type time.Time (timestamps)
The simplistic logging approach we've followed so far is to not log struct
fields that are themselves structs, which time.Time is. So we skipped, but do
log it now.
2025-03-19 21:52:31 +01:00
Mechiel Lukkien
719dc2bee1
webmail: Don't abort SSE connection when a metadata/annotation change is made (broadcasted)
Missing case...
2025-03-16 14:02:45 +01:00
Mechiel Lukkien
26793e407a
imapserver: Fix broadcasting change when modifying metadata key
We were not broadcasting the correct change, at least the modseq was missing in
case of an update.
2025-03-16 13:57:44 +01:00
Mechiel Lukkien
ac4b006ecd
When removing an account, wait for the last account reference has gone away before removing the files.
The intent to remove the account is stored in the database. At startup, if
there are any such referenes, they are applied by removing the account
directories and the entry in the database. This ensures the account directory
is properly removed, even on incomplete shutdown.

Don't add an account when its directory already exits.
2025-03-15 14:20:35 +01:00
Mechiel Lukkien
c4255a96f8
In tests, make initializing store/, its switchboard and an account more consistent.
Initialize store and switchboard first, then open account, and close in reverse
order.

Replace all "CheckClosed" calls with "WaitClosed", future changings will keep
an account reference open for a bit after the last regular close, so we can't
know that an account should be closed during tests.

Remove one parameter from the (still too long) "start test server" function in
imapserver testing code.
2025-03-15 11:15:23 +01:00
Mechiel Lukkien
eadbda027c
Fix bug gathering "changes" to broadcast during a mailbox rename in certain situations
We weren't appending the individual changes to the slice, but the entire slice.
Since "Change" is an "any", this isn't a type error. So make a Change a
non-empty interface (I had seen an issue like this coming, should have made it
an interface then, at least now we have a reasonable method, to get the modseq
of a change).

Found while working on an imap webpush prototype.
2025-03-15 10:45:35 +01:00
Mechiel Lukkien
0cf0bfb8a6
We won't be implementing IMAP UNAUTHENTICATE.
Doesn't seem like it's a common thing to do. And it's just a bit risky, it's
too easy to forget to clear some part of the authentication state on a
connection (especially future changes that forget to update clear a new field
during unauthenticate). If a strong use case ever pops up, we can reconsider.

Also update the roadmap a bit.
2025-03-12 10:01:00 +01:00
Mechiel Lukkien
60da7f34b8
Make error message in imapserver tests about missing untagged responses more readable. 2025-03-10 19:00:44 +01:00
Mechiel Lukkien
397fd1f5e7
imapserver: Make list of announced capabilities more readable.
And merge the duplicate list of capabilities. We had each on a line for
cross-referencing with the RFC, and all capabilities again but on a single line
to use in the server greeting. Now it's just one list.
2025-03-10 11:50:32 +01:00
Mechiel Lukkien
a553a107f0
Cleanup temporary files created during IMAP APPEND command.
Since a recent change (likely since implementing MULTIAPPEND), the temporary
files weren't removed any more. When changing it, I must have had the wrong
mental model about the MessageAdd method, assuming it would remove the temp
file.

Noticed during tests.
2025-03-10 09:26:24 +01:00
Mechiel Lukkien
0857e81a6c
Prevent spurious warnings about thread ids not being correct for messages that are expunged but not yet erased.
Erasing a message (removing the message file from the file system) was made a
separate step a few days ago. The verifydata command checks for consistency of
the data, but didn't correctly skip checking expunged-but-not-yet-erased
messages, leading to the warning.

A similar consistency check in store/account.go does check for that.

I was warned by my nightly backup+veridata script.
2025-03-08 09:03:41 +01:00
Mechiel Lukkien
2314397078
Fix recently introduced bug when authentication with password.
In case the precis check failed, our return of a nil account cleared acc, and
we were then trying to close it, returning in a nil pointer dereference.

Rewrite the return statements so we don't overwrite the named return variables.
2025-03-07 21:30:20 +01:00
Mechiel Lukkien
1c58d38280
webmail: When completing a recipient address, quote the "name" if necessary for proper interpretation.
Especially relevant when the name contains a comma, e.g. "lastname, firstname".
Or when it contains parentheses, e.g. "(organization)".

When sending to an address with a comma that isn't quoted, we would actually
interpret it as two addresses: One without an "@" before the comma, and the
second part after the comma with half of the name and the email addrss. This
resulted in an error message.

When sending to a recipient with unquoted parentheses in the name, those
parentheses would be interpreted as an generic email header comment, and left
out.

For issue #305 by mattfbacon.
2025-03-07 15:48:24 +01:00
Mechiel Lukkien
9a8bb1134b
Allow multiple localpart catch all separators, e.g. both "+" and "-", for addresses you+anything@example.com and you-anything@example.com
The original config option stays, and we still use it for the common case where
we have a single separator. The "+" is configured by default. It is optional,
just like the new option "LocalpartCatchallSeparators" (plural).

When parsing the config file, we combine LocalpartCatchallSeparator and
LocalpartCatchallSeparators into a single list
LocalpartCatchallSeparatorsEffective, which we use throughout the code.

For issue #301 by janc13
2025-03-07 14:42:19 +01:00
Mechiel Lukkien
d0b241499f
smtpserver: In localserve mode, don't reject messages "From" domain "localhost" if it doesn't resolve to an IP
Mox does not look up names from the /etc/hosts file, only through DNS. But
"localhost" may not resolve through DNS, or when offline a DNS server may not
even be available. We will want deliveries to work in "mox localserve" mode.

Found by dstotijn.
2025-03-07 11:39:24 +01:00
Mechiel Lukkien
2fc75b5b7b
When adding a new domain, only set up RSA DKIM keys, not ed25519.
We'll need RSA DKIM keys for a long time to come because many systems don't
support ed25519 DKIM signatures. We've been adding both types of keys when
adding a new domain, and adding both two DKIM signatures to outgoing messages.
This works fine in practice, other mail servers are correctly ignoring the
ed25519 signature if they don't understand it. Unfortunately, it causess noise
in DMARC reports: Systems will warn that a DKIM check failed.  Sometimes with a
vague message about a missing key, or a 0-bit key. Sometimes they leave the
selector out of the report, making it hard to understand what's going on.  This
causes postmasters to investigate because they think something is wrong, only
to eventually find out it's all fine. So we're causing needless chores for
postmasters. By having only an RSA DKIM signature, we skip that noise. This
also reduces the number of DNS records postmasters have to add for a domain.

The small ed25519 DKIM DNS TXT records would make them preferrable over the
long multi-string RSA DKIM DNS TXT records (which are often hard to add
correctly through DNS operator web interfaces), but as mentioned, we'll have to
add the RSA DKIM keys anyway.

Another reason why RSA keys _may_ be preferrable over ed25519 keys is that with
RSA, signing is more computationally expensive than verifying, while it's the
other way around for ed25519 keys.

Admins can always add an ed25519 DKIM key to their domain. And we can always
switch back to adding them to new domains by default in the future.

For issue #299.
2025-03-07 11:15:29 +01:00
Mechiel Lukkien
d78aa9d1d7
Fix previous commit, add missing error check and minor test refactor.
Unclear how I botched this up at the last minute before committing...
2025-03-07 10:30:55 +01:00
Mechiel Lukkien
51f58a52c9
When opening an account, check for unexpected message files in the file system, and adjust the next message ID autoincrement sequence in the database to prevent future message delivery failures.
Just to be cautious. This hasn't happened yet in practice that I'm aware of.
But in theory, mox could crash after it has written the message file during
delivery, but before the database transaction was committed. In that case, a
message file for the "next message id" is already present. Any future delivery
attempts will get assigned the same message id by the database, but writing the
file will fail because there already is one, causing delivery to fail (until
the file is moved away).

When opening an account, we now check in the file system if newer files exist
than we expect based on the last existing message in the database. If so, we
adjust the message ID the database will assign next.
2025-03-07 10:15:27 +01:00
Mechiel Lukkien
493cfee3e1
Mention NLnet funding continued in 2024/2025. 2025-03-06 20:26:25 +01:00
Mechiel Lukkien
64f2f788b1
Run modernize to rewrite some older go constructs to newer ones
Mostly using slice.Sort, using min/max, slices.Concat, range of int and
fmt.Appendf for byte slices instead of strings.
2025-03-06 17:33:06 +01:00
Mechiel Lukkien
f6132bdbc0
imapserver: Disable compress=deflate extension
It still blocks on reading partial flushes from clients, preventing progress
and eventually timing out. The flate library needs more changes to make this
work.

Connections from iOS mail sometimes timed out, not always.

The extension is simply not announced, code is still present.
2025-03-06 11:36:33 +01:00
Mechiel Lukkien
e572d01341
Don't allow mailboxes named "." or ".." and normalize names during imports too
It only serves to confuse. When exporting such mailboxes in zip files or tar
files, extracting will cause trouble.
2025-03-06 11:36:33 +01:00
Mechiel Lukkien
7872b138a5
Use consistent lower-case names when logging tls version and ciphersuite
Less shouty than upper case names.
2025-03-06 11:36:33 +01:00
Mechiel Lukkien
aa2b24d861
webserver: don't raise a 500 server error for static file requests with overlong names
The Open call returns an errno ENAMETOOLONG. We didn't handle that specially,
so turned it into a "500 internal server error" response. When serving static
files, we should just return "404 file not found" errors. The file obviously
does not exist.

Saw a few overlong requests from bad bots not recognizing "data:" uri's inlined
in html files, trying to request them.
2025-03-06 11:36:33 +01:00
Mechiel Lukkien
06b7c8bba0
Fix fuzzing for imapserver
Broken since introducing LoginAttempts. The fuzzing functions didn't get the
store.Init() call, and would hang on trying to send to the loginattemptwriter.
2025-03-06 11:36:33 +01:00
Mechiel Lukkien
edfc24a701
rename a few variables for code consistency 2025-03-06 11:36:33 +01:00
Mechiel Lukkien
96667a87eb
Run go test with the -fullpath flag
Makes it easy to open the file in subpackages when an error occurs.
2025-03-06 11:36:29 +01:00
Mechiel Lukkien
a5c64e4361
make code less indented 2025-03-06 11:35:44 +01:00
Mechiel Lukkien
577944310c
Improve expunged message/UID tracking in IMAP sessions, track synchronization history for mailboxes/annotations.
Keeping the message files around, and the message details in the database, is
useful for IMAP sessions that haven't seen/processed the removal of a message
yet and try to fetch it. Before, we would return errors. Similarly, a session
that has a mailbox selected that is removed can (at least in theory) still read
messages.

The mechanics to do this need keeping removed mailboxes around too. JMAP needs
that anyway, so we now keep modseq/createseq/expunged history for mailboxes
too. And while we're at it, for annotations as well.

For future JMAP support, we now also keep the mailbox parent id around for a
mailbox, with an upgrade step to set the field for existing mailboxes and
fixing up potential missing parents (which could possibly have happened in an
obscure corner case that I doubt anyone ran into).
2025-03-06 11:35:44 +01:00
Mechiel Lukkien
684c716e4d
Add missing wlocks around message delivery to account, mostly for tests. 2025-03-06 11:35:43 +01:00
Mechiel Lukkien
2da280f2bb
Fail tests if unhandled panics happened.
We normally recover from those situations, printing stack traces instead of
crashing the program. But during tests, we're not looking at the prometheus
metrics or all the output. Without these checks, such panics could go
unnoticed. Seems like a reasonable thing to add, unhandled panics haven't been
encountered in tests.
2025-03-06 11:35:43 +01:00
Mechiel Lukkien
bc50c3bf7f
In imapserver with RENAME of Inbox, we didn't check for the metadata quota.
Rename of Inbox is special, it copies the mailbox including metadata.
2025-03-06 11:35:43 +01:00
Mechiel Lukkien
f5b67b5d3d
Clean up the loginattemptclear goroutine with store.Close()
It is called a lot from the test code, so it would spawn lots of those goroutines.
2025-03-06 11:35:43 +01:00
Mechiel Lukkien
2beb30cc20
Refactor how messages are added to mailboxes
DeliverMessage() is now MessageAdd(), and it takes a Mailbox object that it
modifies but doesn't write to the database (the caller must do it, and plenty
of times can do it more efficiently by doing it once for multiple messages).
The new AddOpts let the caller influence how many checks and how much of the
work MessageAdd() does. The zero-value AddOpts enable all checks and all the
work, but callers can take responsibility of some of the checks/work if it can
do it more efficiently itself.

This simplifies the code in most places, and makes it more efficient. The
checks to update per-mailbox keywords is a bit simpler too now.

We are also more careful to close the junk filter without saving it in case of
errors.

Still part of more upcoming changes.
2025-03-06 11:35:43 +01:00
Mechiel Lukkien
7855a32852
switch from docker-compose to "docker compose"
now that my laptop doesn't have docker-compose anymore
2025-03-06 11:35:43 +01:00
Mechiel Lukkien
82371ad15b
simplify cleaning up temp files in gentestdata.go 2025-03-06 11:35:43 +01:00
Mechiel Lukkien
9ce552368b
Minor tweaks. 2025-03-06 11:35:43 +01:00
Mechiel Lukkien
ea64936a67
Cleanup message file when DeliverMailbox fails.
Part of larger changes.
2025-03-06 11:35:43 +01:00
Mechiel Lukkien
5ba51adb14
When retraining ham/spam messages, don't make existence of the messages optional.
If messages that should exist don't, that's a real error we don't want to hide.
Part of larger changes.
2025-03-06 11:35:43 +01:00
Mechiel Lukkien
3b731b7afe
various nits 2025-03-06 11:35:43 +01:00
Mechiel Lukkien
7756150a69
Small tweak to LinkOrCopy, including defer for error handling 2025-03-06 11:35:43 +01:00
Mechiel Lukkien
ffc7ed96bc
When delivering a message to a mailbox, remember last dir we delivered to
In the common case, it's the same as the previous delivery. That means we don't
have to try to create the directory (fewer syscalls) and that we can sync the
dir to disk.

This also tweaks the defer handling in case of a late failure.
2025-03-06 11:35:43 +01:00
Mechiel Lukkien
1037a756fa
when delivering a message to a mailbox, lazily parse the parsed form of the message
it isn't always needed, so this can improve performance a bit.
come up as part of other refactoring.
2025-03-06 11:35:43 +01:00
Mechiel Lukkien
3050baa15a
consistently use store.CloseRemoveTempFile for closing and removing temp files 2025-03-06 11:35:43 +01:00
Mechiel Lukkien
b822533df3
imapserver: Don't keep account write-locked during IMAP FETCH command
We effectively held the account write-locked by using a writable transaction
while processing the FETCH command. We did this because we may have to update
\Seen flags, for non-PEEK attribute fetches. This meant other FETCHes would
block, and other write access to the account too.

We now read the messages in a read-only transaction. We gather messages that
need marking as \Seen, and make that change in one (much shorter) database
transaction at the end of the FETCH command.

In practice, it doesn't seem too sensible to mark messages as seen
automatically. Most clients probably use the PEEK-variant of attribute fetches.

Related to issue #128.
2025-03-06 11:35:43 +01:00
Mechiel Lukkien
caaace403a
Add package smtp as fuzzing target since its addition in previous commit
The previous commit fixed an array out of bounds access that resulted in a
panic on an smtpserver connection. The panic is recovered and marked as
"unhandled panic" in metrics and the connection closed.
2025-03-06 11:15:25 +01:00
Martin Holst Swende
f10bb2c1ae
smtp: add data reader fuzzer + fix OOB read 2025-03-06 09:57:13 +01:00
Mechiel Lukkien
44d37892b8
imapserver: REPLACE commands when in read-only mode should fail 2025-02-26 18:39:41 +01:00
Mechiel Lukkien
d7bd50b5a5
imapserver: fix spurious test failure due to recently added account consistency check
By removing message file while holding the account wlock. We were seeing
messages that weren't removed yet.
2025-02-26 18:33:01 +01:00
Mechiel Lukkien
f235b6ad83
imapclient: log traces of sensitive data with traceauth, and of bulk data with tracedata
Similar to the imapserver. This also fixes tracing of APPEND messages, which
was completely absent before.
2025-02-26 18:13:20 +01:00
Mechiel Lukkien
9c40205343
imapserver: Prevent spurious test failures due to compression layer being closed and TLS close-writes failing 2025-02-26 15:41:46 +01:00
Mechiel Lukkien
062c3ac182
when writing updated word counts to the junk filter, remove entries where both counts are 0
no point in keeping them around.
also pass on error when getting a word from database returned an error.
2025-02-26 15:07:27 +01:00
Mechiel Lukkien
394bdef39d
In storage consistency checks, verify the junk filter has the expected word counts
Fix up a test or two. Simplify the XOR logic when we train the junk filter:
Only if junk or nonjunk is set, but not when both (or none are set). i.e. when
the values aren't the same.

Locking the account when we do consistency checks prevents spurious test
failures that may have been introduced in the previous commit.
2025-02-26 14:44:05 +01:00
Mechiel Lukkien
aa85baf511
add consistency check (enabled during tests only) for unexpected message files in the account message directory 2025-02-26 11:40:36 +01:00
Mechiel Lukkien
17de90e29d
imapserver: Prevent spurious unhandled panics for connections with compress=deflate that break
Writing to a connection goes through the flate library to compress. That writes
the compressed bytes to the underlying connection. But that underlying
connection is wrapped to raise a panic with an i/o error instead of returning a
normal error.  Jumping out of flate leaves the internal state of the compressor
in undefined state. So far so good. But as part of cleaning up the connection,
we could try to flush output again. Specifically: If we were writing user data,
we had switched from tracing of protocol data to tracing of user data, and we
registered a defer that restored the tracing kind and flushed (to ensure data
was traced at the right level). That flush would cause a write into the
compressor again, which could panic with an out of bounds slice access due to
its inconsistent internal state.

This fix prevents that compressor panic in two ways:

1. We wrap the flate.Writer with a moxio.FlateWriter that keeps track of
   whether a panic came out of an operation on it. If so, any further operation
   raises the same panic. This prevents access to the inconsistent internal flate
   state entirely.
2. Once we raise an i/o error, we mark the connection as broken and that makes
   flushes a no-op.
2025-02-26 11:26:54 +01:00
Mechiel Lukkien
ea55c85938
for trace logging, log size of the data (but not for redacted auth data, could be a password) 2025-02-26 10:14:07 +01:00
Mechiel Lukkien
92a87acfcb
Implement IMAP REPLACE extension, RFC 8508.
REPLACE can be used to update draft messages as you are editing. Instead of
requiring an APPEND and STORE of \Deleted and EXPUNGE. REPLACE works
atomically.

It has a syntax similar to APPEND, just allows you to specify the message to
replace that's in the currently selected mailbox. The regular REPLACE-command
works on a message sequence number, the UID REPLACE commands on a uid. The
destination mailbox, of the updated message, can be different. For example to
move a draft message from the Drafts folder to the Sent folder.

We have to do quite some bookkeeping, e.g. for updating (message) counts for
the mailbox, checking quota, un/retraining the junk filter. During a
synchronizing literal, we check the parameters early and reject if the replace
would fail (eg over quota, bad destination mailbox).
2025-02-25 23:27:19 +01:00
Mechiel Lukkien
1066eb4c9f
imapclient: add a type Append for messages for the APPEND-command, and accept multiple for servers with MULTIAPPEND capability
and a few nits.
2025-02-25 23:24:37 +01:00
Mechiel Lukkien
88a68e9143
imapserver: properly accept literal8 for APPEND, since we claim to implement the BINARY extension
it's not just for the APPEND with "UTF8()", also any regular append needs to
accept literal8. found testing with pimalaya.
2025-02-25 23:07:56 +01:00
Mechiel Lukkien
78e0c0255f
imapserver: implement MULTIAPPEND extension, rfc 3502
MULTIAPPEND modifies the existing APPEND command to allow multiple messages. it
is somewhat more involved than a regular append of a single message since the
operation (of adding multiple messages) must be atomic. either all are added,
or none are.

we check as early as possible if the messages won't cause an over-quota error.
2025-02-24 15:47:47 +01:00
Mechiel Lukkien
b56d6c4061
imapserver: try harder to get the user-agent (from the ID command) into the loginattempt
our previous approach was to hope clients did the ID command right after the
AUTHENTICATE command. with more extensions implemented, that's a stretch,
clients are doing other commands in between.

the new approach is to allow more commands, but wait at most 1 second. clients
are still assumed to send the ID command soon after authenticate. we also still
ensure login attempts are logged on connection teardown, so we aren't missing
any logging, just may get it slightly delayed. seems reasonable.

we now also keep the useragent value around, and we use when initializing the
login attempt. because the ID command can happen at any time, also before the
AUTHENTICATE command.
2025-02-24 09:54:38 +01:00
Mechiel Lukkien
d27fc1e7fc
gofmt 2025-02-23 22:40:34 +01:00
Mechiel Lukkien
f117cc0fe1
website: mention tls-alpn-01 and http-01 acme challenge types are implemented, but not dns-01 yet
prompted by question by rawtaz on irc
2025-02-23 22:28:07 +01:00
Mechiel Lukkien
0ed820e3b0
imapserver: implement rfc 9590, returning metadata in the extended list command
only with "return" including "metadata". so clients can quickly get certain
metadata (eg for display, such as a color) for mailboxes.

this also adds a protocol token type "mailboxt" that properly encodes to utf7
if required.
2025-02-23 22:12:18 +01:00
Mechiel Lukkien
2809136451
imap metadata extension: allow keys in the /shared/ namespace too
not just /private. /shared/ is the more commonly implemented namespace, because
it is easier te implement: you don't need per-user/account storage of metadata.
i initially approached it from the other direction: we don't have a mechanism
to share metadata with other accounts, so everything is private, and i assumed
that would be what a user would prefer. but email clients make the decisions,
and they'll likely try the /shared/ namespace.
2025-02-23 20:19:07 +01:00
Mechiel Lukkien
463e801909
add more rfc's and shuffle roadmap once more 2025-02-23 12:08:11 +01:00
Mechiel Lukkien
3b224ea0c2
consistent simpler parsing of domains in cli commands
prompted by previous commit, making me look at dns.ParseDomain calls.
2025-02-23 11:34:51 +01:00
Mechiel Lukkien
151729af08
in dns.ParseDomain, don't allow ipv4 addresses (ipv6 addresses were already rejected)
we are expecting a DNS domain name there.
also highlighted a wrong test in the smtp server.
2025-02-23 11:33:31 +01:00
Mechiel Lukkien
797c1cf9f0
do not log an error for tls requests with ipv6 addresses as sni server name
ip addresses are invalid in server names. for ipv6 addresses, the
autocert.GetCertificate calls would return an error, which we logged, and
increased a metric about. but the alerts for this situation aren't helpful. so
recognize ip addresses early. if we are lenient about unknown server names (for
incoming smtp deliveries), we switch to the fallback hostname, otherwise we
return an error.

this was the error logged:

	l=error m="requesting certificate" err="acme/autocert: server name component count invalid"

for ipv4 addresses, the name wouldn't be in our allowlist and should already
have caused us to switch to the fallback hostname.
2025-02-23 10:46:39 +01:00
Mechiel Lukkien
cad585a70e
webmail: when trying to empty an already empty mailbox, make it a user error, not server error
server errors could cause error logging.
2025-02-22 23:11:34 +01:00
Mechiel Lukkien
9f3cb7340b
update modseq when changing mailbox/server metadata, and also for specialuse changes, and keep track of modseq for mailboxes
i added the metadata extension to the imapserver recently. then i wondered how
a client would efficiently find changed metadata. turns out the qresync rfc
mentions that metadata changes should set a new modseq on the mailbox.
shouldn't be hard, except that we were not explicitly keeping track of modseqs
per mailbox. we only kept them for messages, and we were just looking up the
latest message modseq when we needed the modseq (we keep db entries for
expunged messages, so this worked out fine). that approach isn't enough
anymore. so know we keep track of modseq & createseq for mailboxes, just as for
messages. and we also track modseq/createseq for annotations. there's a good
chance jmap is going to need it.

this also adds consistency checks for modseq/createseq on mailboxes and
annotations to the account storage. it helped spot cases i missed where the
values need to be updated.
2025-02-22 22:52:18 +01:00
Mechiel Lukkien
7c7473ef0e
fix tests on bsds, since previous commit
the tls resumption test was failing due to switch from net.Pipe to unix domain
socket pairs. on bsds, they have an empty name (on linux it is "@"), which
prevents tls resumption from working.
2025-02-21 20:38:37 +01:00
Mechiel Lukkien
f40f94670e
implement IMAP extension COMPRESS=DEFLATE, rfc 4978
to compress the entire IMAP connection. tested with thunderbird, meli, k9, ios
mail. the initial implementation had interoperability issues with some of these
clients: if they write the deflate stream and flush in "partial mode", the go
stdlib flate reader does not return any data (until there is an explicit
zero-length "sync flush" block, or until the history/sliding window is full),
blocking progress, resulting in clients closing the seemingly stuck connection
after considering the connection timed out. this includes a coy of the flate
package with a new reader that returns partially flushed blocks earlier.

this also adds imap trace logging to imapclient.Conn, which was useful for
debugging.
2025-02-21 14:56:17 +01:00
Mechiel Lukkien
3f6c45a41f
for trace-level logging in console format (as opposed to logfmt), print the trace as quoted string
so we can easily see the exact bytes on the wire, instead of having \n's
expanded as newlines. much easier to read. we had this in the past, but it must
have been lost in a refactor.
2025-02-20 17:42:00 +01:00
Mechiel Lukkien
95d2002e77
announce support for namespace extension in imap capabilities line
we already implemented it as part of imap4rev2, but older clients need to be
told we implement it.
2025-02-20 08:32:33 +01:00
Mechiel Lukkien
a458920721
pass "go vet" again, can't use unkeyed struct fields from other package 2025-02-19 23:06:11 +01:00
Mechiel Lukkien
6ed97469b7
imapclient: parse fetch attribute "internaldate" as time.Time instead of keeping it as string
similar to the SAVEDATE fetch attribute implemented recently.
2025-02-19 23:01:23 +01:00
Mechiel Lukkien
02c4715724
remove intention to implement \important special-use mailbox and $important message flag, rfc 8457
they are intended to be used by the server to automatically mark some messages
as important, based on server-defined heuristics. we don't have such heuristics
at the moment. perhaps in the future, but until then there are no plans.
2025-02-19 22:44:04 +01:00
Mechiel Lukkien
5e4d80d48e
implement the WITHIN IMAP extension, rfc 5032
for IMAP "SEARCH" command criteria "YOUNGER" and "OLDER".
2025-02-19 21:29:14 +01:00
Mechiel Lukkien
dcaa99a85c
implement IMAP CREATE-SPECIAL-USE extension for the mailbox create command, part of rfc 6154
we already supported special-use flags. settable through the webmail interface,
and new accounts already got standard mailboxes with special-use flags
predefined. but now the IMAP "CREATE" command implements creating mailboxes
with special-use flags.
2025-02-19 20:39:26 +01:00
Mechiel Lukkien
7288e038e6
implement imap savedate extension, rfc 8514
it makes a new field available on stored messages. not when they they were
received (over smtp) or appended to the mailbox (over imap), but when they were
last "saved" in the mailbox. copy/move of a message (eg to the trash) resets
the "savedate" value. this helps implement "remove messages from trash after X
days".
2025-02-19 17:11:20 +01:00
Mechiel Lukkien
cbe5bb235c
fix data race in code for logging login attempts
logging of login attempts happens in the background, because we don't want to
block regular operation with disk since for such logging. however, when a line
is logged, we evaluate some attributes of a connection, notably the username.
but about when we do authentication, we change the username on a connection. so
we were reading and writing at the same time. this is now fixed by evaluating
the attributes before we pass off the logger to the goroutine.

found by the go race detector.
2025-02-19 15:23:19 +01:00
Mechiel Lukkien
de6262b90a
make test for imap getmetadata reliable by sorting output by key 2025-02-19 14:58:22 +01:00
Mechiel Lukkien
f30c44eddb
implement the imap metadata extension, rfc 5464
this allows setting per-mailbox and per-server annotations (metadata). we have
a fixed maximum for total number of annotations (1000) and their total size
(1000000 bytes). this size isn't held against the regular quota for simplicity.
we send unsolicited metadata responses when a connection is in the idle
command and a change to a metadata item is made.

we currently only implement the /private/ namespace.  we should implement the
/shared/ namespace, for mox-global metadata annotations.  only the admin should
be able to configure those, probably through the config file, cli, or admin web
interface.

for issue #290
2025-02-17 22:57:33 +01:00
Mechiel Lukkien
9dff879164
in domain/dns self-check, for unused services, check that port is 0 like how we told users to configure it and fix checking for errors during srv lookups 2025-02-16 17:42:24 +01:00
Mechiel Lukkien
1c4bf8909c
webmail: when forwarding, include the subject,date,from,reply-to,to,cc headers in the message
mentioned some time ago by ilijamt
2025-02-16 16:45:02 +01:00
Mechiel Lukkien
4765bf3b2c
shuffle entries in roadmap
it hasn't been updated in a while. this isn't the full picture either, but at
least closer to the planned order.
2025-02-16 16:28:48 +01:00
Mechiel Lukkien
3d0dc3a79d
in domain/dns self-check, for unexpected SRV records for "srv autoconfig", show the values of the unexpected records
should be more helpful in understanding what's wrong.

feedback from mteege, thanks!
2025-02-16 16:21:01 +01:00
Mechiel Lukkien
6f678125a5
in domain/dns self-check, provide config snippet for HostTLSRPT if it isn't configured and the admin should check again for the DNS records
feedback from mteege, thanks!
2025-02-16 16:12:44 +01:00
Mechiel Lukkien
1d6f45e592
in domain/dns self-check, don't warn about reverse dns that resolves to multiple names
this is fine. we just need to check if the expected name is among them.

feedback from mteege, thanks!
2025-02-16 15:55:31 +01:00
Mechiel Lukkien
6da5f8f586
add config option to an account destination to reject messages that don't pass a dmarc-like aligned spf/aligned dkim check
intended for automated processors that don't want to send messages to senders
without verified domains (because the address may be forged, and the processor
doesn't want to bother innocent bystanders).

such delivery attempts will fail with a permanent error immediately, typically
resulting in a DSN message to the original sender. the configurable error
message will normally be included in the DSN, so it could have alternative
instructions.
2025-02-15 17:34:06 +01:00
Mechiel Lukkien
f33870ba85
move the large commands for generating api docs to separate shell script 2025-02-15 12:56:59 +01:00
Mechiel Lukkien
3e53abc4db
add account config option to prevent the account for setting their own custom password, and enable by default for new accounts
accounts with this option enabled can only generate get a new randomly
generated password. this prevents password reuse across services and weak
passwords. existing accounts keep their current ability to set custom
passwords. only admins can change this setting for an account.

related to issue #286 by skyguy
2025-02-15 12:44:18 +01:00
Mechiel Lukkien
09975a3100
when warning about weak passwords, mention that passwords reused at other services in particular
based on issue #286
2025-02-15 11:48:10 +01:00
Mechiel Lukkien
46c1693ee9
when delivering over smtp, do not require the other server to announce the 8bitmime extension unless in pedantic mode
all relevant systems nowadays should be accepting "8-bit" messages. before this
change, we would fail delivery for 8bit messages when the remote server doesn't
announce the 8bitmime smtp extension.  even though that system would likely
just accept our message.

also, there's a good chance the non-8bitmime-supporting system is some
intermediate minimal mail server like openbsd spamd, which was fixed to
announce the 8bitmime extension in the past year.

in theory, we could rewrite the message to be 7bit-only if it is a mime
message. but it's probably not worth the trouble.  also see
https://cr.yp.to/smtp/8bitmime.html

as alternative to PR #287 by mattanja (who also reported the issue on matrix),
thanks!
2025-02-15 10:11:17 +01:00
BlankEclair
93b627ceab
main: fix reading passwords longer than 64 bytes
Fixes #284
2025-02-09 22:55:38 +11:00
Mechiel Lukkien
c210b50433
update publicsuffix list to latest version
and add note to (pre)release process to update it
2025-02-07 12:02:39 +01:00
Mechiel Lukkien
2f0997682b
quickstart: check if domain was registered recently, and warn about potential deliverability issues
we use 6 weeks as the cutoff, but this is fuzzy, and will vary by mail
server/service provider.

we check the domain age using RDAP, the replacement for whois. it is a
relatively simple protocol, with HTTP/JSON requests. we fetch the
"registration"-related events to look for a date of registration.
RDAP is not available for all country-level TLDs, but is for most (all?) ICANN
global top level domains. some random cctlds i noticed without rdap: .sh, .au,
.io.

the rdap implementation is very basic, only parsing the fields we need. we
don't yet cache the dns registry bootstrap file from iana. we should once we
use this functionality from the web interface, with more calls.
2025-02-07 11:22:39 +01:00
Mechiel Lukkien
c7354cc22b
also unicode-normalize usernames (email addresses) when logging into the imapserver and webapps
and don't do needless normalization for the username we got from scram: the
scram package would have failed if the name wasn't already normalized.

unicode may not be specified for sasl with imap (i'm not sure), but there's no
point in accepting it over smtpserver but not in imapserver.
2025-02-06 15:38:45 +01:00
Mechiel Lukkien
7b3ebb2647
imapserver: remove unreachable check for logindisabled
given the "false" flag above when opening the account by email.
the login disabled case is handled after the various auth mechanisms in a
single place.

noticed while making other changes.
2025-02-06 15:28:01 +01:00
Mechiel Lukkien
e5e15a3965
add prometheus metrics for errors when getting certificates through acme (typically from let's encrypt)
and add an alerting rule for it.
we certainly want a heads up when there are issues with the certificates.
2025-02-06 15:12:36 +01:00
Mechiel Lukkien
1277d78cb1
keep track of login attempts, both successful and failures
and show them in the account and admin interfaces. this should help with
debugging, to find misconfigured clients, and potentially find attackers trying
to login.

we include details like login name, account name, protocol, authentication
mechanism, ip addresses, tls connection properties, user-agent. and of course
the result.

we group entries by their details. repeat connections don't cause new records
in the database, they just increase the count on the existing record.

we keep data for at most 30 days. and we keep at most 10k entries per account.
to prevent unbounded growth. for successful login attempts, we store them all
for 30d. if a bad user causes so many entries this becomes a problem, it will
be time to talk to the user...

there is no pagination/searching yet in the admin/account interfaces. so the
list may be long. we only show the 10 most recent login attempts by default.
the rest is only shown on a separate page.

there is no way yet to disable this. may come later, either as global setting
or per account.
2025-02-06 14:16:13 +01:00
Mechiel Lukkien
d08e0d3882
webmail: fix dark mode
broken in v0.0.14, probably when introducing the css variables.
i had noticed this issue at the time, and thought i fixed it, but clearly not.

for issue #278, reported by gdunstone
2025-02-03 18:28:48 +01:00
Mechiel Lukkien
091faa8048
webmail: fix parsing search filter "start:<date>" and "end:<date>"
we were only properly parsing values of "<date>T<time>" or just "<time>".
so you could select a date in the form (or type it), but it would be treated as
just a word of text to search for in messages. so it would quietly do the wrong
thing.
2025-01-30 12:15:44 +01:00
Mechiel Lukkien
ef77f58e08
webmail: add button to create a mailbox below another one
before this, you could use the button at the top of the list of mailboxes to
create a submailbox somewhere, and you would have to specify the full path of
the new mailbox name. now you can just open up your Lists/.../ mailbox, and
create a mailbox below that hierarchy.
2025-01-30 11:55:57 +01:00
Mechiel Lukkien
ad26fd265d
webmail: add button to mark a mailbox and its children as read
this sets the seen flag on all messages in the mailbox and its children.
2025-01-30 11:50:52 +01:00
Mechiel Lukkien
c8fd9ca664
webmail: after clicking on the "create mailbox" button, automatically put focus on the input field for the new mailbox name 2025-01-30 11:02:12 +01:00
Mechiel Lukkien
f9280b0891
reduce logging about db schema initializations during tests
they were a bit too noisy, not helpful
2025-01-30 10:21:16 +01:00
Mechiel Lukkien
807d01ee21
simplify/cleanup common smtpserver test code 2025-01-29 21:56:00 +01:00
Mechiel Lukkien
ec7904c0ee
add fail2ban snippet to FAQ
from unguamorray in issue #274
2025-01-29 20:58:31 +01:00
Mechiel Lukkien
df17ae2321
in email to postmaster about new mox version, don't mention "mox backup" explicitly, it's in all the release notes nowadays 2025-01-29 20:27:33 +01:00
Mechiel Lukkien
6ed736241d
also use "password-encrypted" for the 2nd autoconfig configuration
intended for deltachat, which doesn't look at the value. encrypted may be a
better default.

as discussied in #251
2025-01-27 08:31:13 +01:00
Mechiel Lukkien
49e2eba52b
add cli command "mox admin imapserve $preauthaddress"
for admins to open an imap connection preauthenticated for an account (by address), also when
it is disabled for logins.

useful for migrations. the admin typically doesn't know the password of the
account, so couldn't get an imap session (for synchronizing) before.

tested with "mox localserve" and running:

	mutt -e 'set tunnel="MOXCONF=/home/mjl/.config/mox-localserve/mox.conf ./mox admin imapserve mox@localhost"'

may also work with interimap, but untested.

i initially assumed imap would be done fully on file descriptor 0, but mutt
expects imap output on fd 1. that's the default now. flag -fd0 is for others
that expect it on fd0.

for issue #175, suggested by DanielG
2025-01-25 22:18:26 +01:00
Mechiel Lukkien
2d3d726f05
add config options to disable a domain and to disable logins for an account
to facilitate migrations from/to other mail setups.

a domain can be added in "disabled" mode (or can be disabled/enabled later on).
you can configure a disabled domain, but incoming/outgoing messages involving
the domain are rejected with temporary error codes (as this may occur during a
migration, remote servers will try again, hopefully to the correct machine or
after this machine has been configured correctly). also, no acme tls certs will
be requested for disabled domains (the autoconfig/mta-sts dns records may still
point to the current/previous machine). accounts with addresses at disabled
domains can still login, unless logins are disabled for their accounts.

an account now has an option to disable logins. you can specify an error
message to show. this will be shown in smtp, imap and the web interfaces. it
could contain a message about migrations, and possibly a URL to a page with
information about how to migrate. incoming/outgoing email involving accounts
with login disabled are still accepted/delivered as normal (unless the domain
involved in the messages is disabled too). account operations by the admin,
such as importing/exporting messages still works.

in the admin web interface, listings of domains/accounts show if they are disabled.
domains & accounts can be enabled/disabled through the config file, cli
commands and admin web interface.

for issue #175 by RobSlgm
2025-01-25 20:39:20 +01:00
Mechiel Lukkien
132efdd9fb
don't use non-constant for string formatting
found by go1.24rc
2025-01-24 17:00:39 +01:00
Mechiel Lukkien
3e2695323c
add config option to reject incoming deliveries with an error during the smtp transaction
useful when a catchall is configured, and messages to some address need to be
rejected.

would have been nicer if this could be part of a ruleset. but evaluating a
ruleset requires us to have the message (so we can match on headers, etc). but
we can't reject messages to individual recipients during the DATA command in
smtp. that would reject the entire delivery attempt.

for issue #156 by ally9335
2025-01-24 16:51:21 +01:00
Mechiel Lukkien
8b26e3c972
consistently add details about configuration errors when parsing domains.conf
e.g. which domain, account, address, alias, the error is about.

we were adding context some of the time. this introduces helpers for adding
errors that make it easier to add details to the error messages.
2025-01-24 15:06:55 +01:00
Mechiel Lukkien
890c75367a
mox backup: skip message files that were added to queue or account message directories while making the backup, instead of storing them and warning about them
by storing them, a restore may need the -fix flag to become usable again.
it makes more sense to just skip these files. they are not part of the
consistent snapshot.
2025-01-24 12:24:57 +01:00
Mechiel Lukkien
76e96ee673
Change "mox backup $destdir" from storing only data files to $destdir to storing those under $destdir/data and now also copying config files to $destdir/config. (#150)
Upgrade note: Admins may want to check their backup scripts.

Based on feedback in issue #150.
2025-01-24 11:45:43 +01:00
Mechiel Lukkien
3d52efbdf9
fix apidiff.sh to always generate a new apidiff/next.txt file 2025-01-23 23:02:36 +01:00
Mechiel Lukkien
6aa2139a54
do not use results from junk filter if we have less than 50 positive classifications to base the decision on
useful for new accounts. we don't want to start rejecting incoming messages for
having a score near 0.5 because of too little training material. we err on the
side of allowing messages in. the user will mark them as junk, training the
filter. once enough non-junk has come in, we'll start the actual filtering.

for issue #64 by x8x, and i've also seen this concern on matrix
2025-01-23 22:55:50 +01:00
Mechiel Lukkien
8fac9f862b
attempt to fix workflow again
sigh, this is why you don't you use cloud tools that you can't run locally...
2025-01-23 18:40:05 +01:00
Mechiel Lukkien
7df54071d7
update to github action actions/upload-artifact@v4 from v3
we'll now get a coverage file artifact for each of the builds. we do two
builds, and the last was likely overwriting the coverage file "artifact" of the
first.

hopefully fixes the test. can't test it locally...
2025-01-23 18:29:43 +01:00
Mechiel Lukkien
acc1c133b0
admin check: do not raise error when forward-confirmed reverse dns does not match hostname
this should be relatively common with setups involving NAT.
so we do warn about it when NAT isn't active since it could highlight potential
misconfiguration.

for issue #239 by exander77
2025-01-23 18:11:00 +01:00
s0ph0s
3c77e076e2
Add support for negotiating IMAP and SMTP on the HTTPS port 443 using TLS ALPN "imap" and "smtp"
Intended for future use with chatmail servers. Standard email ports may be
blocked on some networks, while the HTTPS port may be accessible.

This is a squashed commit of PR #255 by s0ph0s-dog.
2025-01-23 11:16:20 +01:00
Mechiel Lukkien
0203dfa9d9
webmail: fix nil pointer dereference when searching for attachment types, eg "a:spreadsheet"
for issue #272 by mattfbacon
2025-01-23 11:03:08 +01:00
Mechiel Lukkien
008de1cafb
webmail: in message view, under More, add button to open currently displayed part (either text or html) as raw text (but decoded if in base64/quoted-printable/etc). 2025-01-22 21:19:24 +01:00
Mechiel Lukkien
7647264a72
web interfaces: when there is no login session, and a non-existent path is requested, mention the web interface this is about
may help users understand when /admin/ isn't enabled on a hostname but the
account web interface is at /. the error will now say: no session for "account"
web interface. it hopefully tells users that their request isn't going to an
admin interface, but ends up at the account web interface.

for issue #268
2025-01-22 20:15:14 +01:00
Mechiel Lukkien
f15f2d68fc
webmail: more helpful error message when emptying a mailbox that is already empty
and mention in a tooltip too that "empty mailbox" only affects messages in the
mailbox, not submailboxes or their messages.

prompted by a question on matrix/irc
2025-01-22 20:09:19 +01:00
Mechiel Lukkien
315f10d5f2
add release to website 2025-01-20 12:54:45 +01:00
Mechiel Lukkien
5fcea1eb3b
rotate apidiff/next.txt for release 2025-01-20 12:49:20 +01:00
Mechiel Lukkien
be1065a6c4
add another makefile testing target 2025-01-13 23:23:00 +01:00
Mechiel Lukkien
b85401a83d
fix command gentestdata for testing upgrades
not working since tlspubkey auth
2025-01-13 23:22:14 +01:00
Mechiel Lukkien
dd92ed5117
update to latest golang.org/x dependencies 2025-01-13 22:29:42 +01:00
Mechiel Lukkien
871f70151c
smtpserver: allow using an "message from" address from an allowed alias as smtp mail from during submission
mail clients will use these message from addresses also for smtp mail from, so
sending over smtp would fail for these cases. for the webmail and webapi they
already succeeded since we just took the "message from" address as "smtp mail
from" address.

for issue #266 by Robby-, thanks for reporting!
2025-01-13 21:34:59 +01:00
Mechiel Lukkien
d4d2a0fd99
webmail: when listing messages in backend to send to frontend, don't error out when there's a large plain text part
by not trying to parse the full message for the MessageItem, but only reading
headers when needed.

before previous commit, we wouldn't try reading such messages in full either.
2025-01-13 16:13:25 +01:00
Mechiel Lukkien
1e15a10b66
webmail: fix js error rerendering additional headers after updated keywords
i've seen the error a few times:

	msgheaderElem.children[(msgheaderElem.children.length - 1)] is undefined

i've seen it happen after sending a reply (with the "answered" flag added).
the updateKeywords callback would render the message again, but the code for
rendering the "additional headers" table rows again was making invalid
assumptions.

the approach is now changed. the backend now just immediately sends the
additional headers to the frontend. before, the frontend would first render the
base message, then render again once the headers came in for the parsed
message. this also prevents a reflow for the (quite common) case that one of
the additional headers are present in the message.
2025-01-13 14:53:43 +01:00
Mechiel Lukkien
f7193bd4c3
webmail: fix css to not show text on button (actually html "a" element for links) for downloaded (visited) attachments in blue 2025-01-13 11:22:44 +01:00
Mechiel Lukkien
5a14a5b067
smtpserver: when doing slow writes due to spammy incoming delivery, try a bit harder to prevent a timeout for the other side (if it is mox/itself!)
based on question from wneessen
2025-01-13 11:13:26 +01:00
Mechiel Lukkien
b8bf99e082
ensure kind "acme-tls-alpn-01" is registered on the http handler
previous code couldn't possibly be triggered by my reading.

encountered during PR #255
2025-01-13 10:43:55 +01:00
Mechiel Lukkien
eb88e2651a
dkim: add reference to rfc that says not to accept rsa keys < 1024 bits
saw it mentioned on HN recently
2025-01-13 10:35:25 +01:00
Mechiel Lukkien
e5eaf4d46f
fix race in imapserver tests 2024-12-25 16:50:23 +01:00
Mechiel Lukkien
9b429cce4f
try harder to start docker integration tests with clean slate
for some reason "docker-compose down" takes a very long time, and doesn't
actually stop containers if you add a timeout.
2024-12-25 16:44:54 +01:00
Mechiel Lukkien
965a2b426f
webadmin: when loading page with webserver routes, internal services would always be shown with "admin" as internal services, and saving the handler would overwrite the correct setting
fix this by properly loading the correct internal service.

for issue #264 reported by kiekerjan, thanks!
2024-12-24 22:02:28 +01:00
Mechiel Lukkien
f7666d1582
fix verifying dane-ta connections for outgoing email where the dane-ta record is not for the first certificate in the chain after the leaf certifiate.
tls servers send a list of certificates for the connection. the first is the
leaf certificate. that's the one for the server itself. that's the one we want
to verify. the others are intermediate CA's. and possibly even the root CA
certificate that it hopes is trusted at the client (though sending it doesn't
make it trusted). with dane-ta, the public key of an intermediate or root CA
certificate is listed in the TSLA record. when verifying, we add any
intermediate/root CA that matches a dane-ta tlsa record to the trusted root CA
certs. we should also have added CA certs that didn't match a TLSA record to
the "intermediates" of x509.VerifyOptions. because we didn't,
x509.Certificate.Verify couldn't verify the chain from the trusted dane-ta ca
cert to the leaf cert. we would only properly verify a dane-ta connection
correctly if the dane-ta-trusted ca cert was the one immediately following the
leaf cert. not when there were one or more additional intermediate certs.

this showed when connecting to mx.runbox.com.

problem reported by robbo5000 on matrix, thanks!
2024-12-21 16:09:53 +01:00
Mechiel Lukkien
aa9a06680f
update to golang.org/x/net/html (slow parsing fixed) and other golang.org/x deps 2024-12-21 09:44:11 +01:00
Mechiel Lukkien
d082aaada8
only use constant strings in string formatting
builds with go1.24rc1 fail on these.
only the case in smtpserver could be triggered externally.
2024-12-14 09:38:56 +01:00
Mechiel Lukkien
5320ec1c5b
quickstart: for -existing-webserver, also tls key/cert placeholder for mail.$domain
unless mail.$domain is the mx hostname.

after question about which tls certs are needed from robbo5000 on matrix
2024-12-08 10:18:57 +01:00
Mechiel Lukkien
2255ebcf11
quickstart: write all output to a file "quickstart.log" for later reference
quite some output is printed. you could remember to tee it all to a file. but
that's probably often realized only after having run the quickstart. you can
also copy/paste it all from the terminal, but that's sometimes annoying to do.
writing to a file is more helpful to users.

this has been requested a few times in the past on irc/matrix (i forgot who).
2024-12-07 21:14:43 +01:00
Mechiel Lukkien
35af7e30a6
do not try to get a tls cert for autoconfig.<domain> at startup if there is no listener with autoconfig enabled
reduces needless logging in setups that don't use autoconfig.
2024-12-07 20:28:52 +01:00
Mechiel Lukkien
cbe418ec59
try clarifying that aliases are lists, not to be used for simply adding an address to an account
for issue #244 by exander77
2024-12-07 19:10:02 +01:00
Mechiel Lukkien
f7b58c87b1
instead of using loglevel error for printing a warning, just log it as "warn" error level, and don't log message parsing errors as loglevel error 2024-12-07 19:07:16 +01:00
Mechiel Lukkien
94fb48c2dc
mox retrain: make the parameter, for account, optional and retrain all accounts when absent
for more easily retraining all accounts. users should be retraining their
accounts with the next release, due to the fix in the previous commit.
2024-12-07 17:00:00 +01:00
Mechiel Lukkien
17baf9a883
junk filter: fix adjusting word counts after train/untrain
after seeing some junk messages pass the filter, i investigated word counts in
junkfilter.db. i had seen suspicious counts that were just around powers of
two. did not make sense at the time. more investigating makes it clear: instead
of setting new word counts when updating the junk filter, we were adding the
new value to the current value (instead of just setting the new value). so the
counts got approximately doubled when being updated.

users should retrain the junk filter after this update using the "retrain"
subcommand.

this also adds logging for the hypothetical case where numbers would get
decreased below zero (which would wrap around due to uints).

and this fixes junk filter tests that were passing wrong parameters to
train/untrain...
2024-12-07 16:53:53 +01:00
Mechiel Lukkien
69a4995449
move func PartStructure from webhook to queue, so it isn't tracked anymore for apidiff changes
the types in webhook should be subjected to apidiff'ing, this was a shared
function. it is better off in package queue. also change the apidiff script so
it leaves apidiff/next.txt empty when there aren't any changes. makes it easier
to rotate the files after releases where nothing changed (a common occurrence).
2024-12-07 13:57:07 +01:00
Mechiel Lukkien
0871bf5219
move checking whether a message needs smtputf8 (has utf8 in any of the header sections) to package message 2024-12-07 13:05:09 +01:00
Mechiel Lukkien
3f727cf380
webmail: move 2 config options from localstorage to the settings popup, storing their values on the server
these settings are applied anywhere the webmail is open.  the settings are for
showing keyboard shortcuts in the lower right after a mouse interaction, and
showing additional headers.  the shorcuts were configurable in the "help" popup
before.  the additional headers were only configurable through the developer
console before.

the "mailto:" (un)register buttons are now in the settings popup too.
2024-12-07 12:32:54 +01:00
Mechiel Lukkien
4d3c4115f8
webmail: don't bind to shortcuts ctrl-l, ctrl-u and ctrl-I
ctrl-l is commonly "focus on browser address bar".
ctrl-u is commonly "view source".
ctrl-I (shift i) is commonly "open developer console".

these keys are more useful to leave for the browser.  ctrl-l and ctrl-u (moving
to a message without opening it) can still be had by using also pressing shift.
the previous ctrl-shift-i (show all headers) is now just ctrl-i.

this has been requested in the past on irc/matrix (i forgot who).
2024-12-07 12:29:12 +01:00
Mechiel Lukkien
0a77bc5955
tweak doucmentation for sasl and scram 2024-12-06 15:59:22 +01:00
Mechiel Lukkien
ce75852b7c
add missing space in x-mox-reason that's been bothering me for a while 2024-12-06 15:49:22 +01:00
Mechiel Lukkien
b750668152
add metrics that track how many error/warn/info logging is happening 2024-12-06 15:07:42 +01:00
Mechiel Lukkien
056b571fb6
webmail: don't consume keyboard events while login form is open
e.g. ctrl-l, for going to address bar to go to another site.
2024-12-06 14:57:20 +01:00
Mechiel Lukkien
e59f894a94
add an option for the smtp delivery listener to enable/disable tls session tickets
the field is optional. if absent, the default behaviour is currently to disable
session tickets. users can set the option if they want to try if delivery from
microsoft is working again. in a  future version, we can switch the default to
enabling session tickets.

the previous fix was to disable session tickets for all tls connections,
including https. that was a bit much.

for issue #237
2024-12-06 14:50:02 +01:00
Mechiel Lukkien
42793834f8
add Content-Disposition and Filename to the payload of incoming webhooks
for each message part. The ContentDisposition value is the base value without
header key/value parameters. the Filename field is the likely filename of the
part. the different email clients encode filenames differently. there is a
standard mime mechanism from rfc 2231. and there is the q/b-word-encoding from
rfc 2047. instead of letting users of the webhook api deal with those
differences, we provide just the parsed filename.

for issue #258 by morki, thanks for reporting!
2024-12-06 14:19:39 +01:00
Mechiel Lukkien
8804d6b60e
implement tls client certificate authentication
the imap & smtp servers now allow logging in with tls client authentication and
the "external" sasl authentication mechanism. email clients like thunderbird,
fairemail, k9, macos mail implement it. this seems to be the most secure among
the authentication mechanism commonly implemented by clients. a useful property
is that an account can have a separate tls public key for each device/email
client.  with tls client cert auth, authentication is also bound to the tls
connection. a mitm cannot pass the credentials on to another tls connection,
similar to scram-*-plus. though part of scram-*-plus is that clients verify
that the server knows the client credentials.

for tls client auth with imap, we send a "preauth" untagged message by default.
that puts the connection in authenticated state. given the imap connection
state machine, further authentication commands are not allowed. some clients
don't recognize the preauth message, and try to authenticate anyway, which
fails. a tls public key has a config option to disable preauth, keeping new
connections in unauthenticated state, to work with such email clients.

for smtp (submission), we don't require an explicit auth command.

both for imap and smtp, we allow a client to authenticate with another
mechanism than "external". in that case, credentials are verified, and have to
be for the same account as the tls client auth, but the adress can be another
one than the login address configured with the tls public key.

only the public key is used to identify the account that is authenticating. we
ignore the rest of the certificate. expiration dates, names, constraints, etc
are not verified. no certificate authorities are involved.

users can upload their own (minimal) certificate. the account web interface
shows openssl commands you can run to generate a private key, minimal cert, and
a p12 file (the format that email clients seem to like...) containing both
private key and certificate.

the imapclient & smtpclient packages can now also use tls client auth. and so
does "mox sendmail", either with a pem file with private key and certificate,
or with just an ed25519 private key.

there are new subcommands "mox config tlspubkey ..." for
adding/removing/listing tls public keys from the cli, by the admin.
2024-12-06 10:08:17 +01:00
Mechiel Lukkien
5f7831a7f0
move config-changing code from package mox-/ to admin/
needed for upcoming changes, where (now) package admin needs to import package
store. before, because package store imports mox- (for accessing the active
config), that would lead to a cyclic import. package mox- keeps its active
config, package admin has the higher-level config-changing functions.
2024-12-02 22:03:18 +01:00
Mechiel Lukkien
de435fceba
switch to math/rand/v2 in most places
this allows removing some ugly instantiations of an rng based on the current
time.

Intn is now IntN for our concurrency-safe prng wrapper to match the randv2 api.

v2 exists since go1.22, which we already require.
2024-11-29 13:45:19 +01:00
Mechiel Lukkien
96a3ecd52c
use reflect.TypeFor instead of kludgy reflect.TypeOf
TypeFor was introduced in go1.22, which we already require.
2024-11-29 13:17:13 +01:00
Mechiel Lukkien
afb182cb14
smtpserver: add prometheus metric for failing starttls handshakes for incoming deliveries
and add an alerting rule if the failure rate becomes >10% (e.g. expired
certificate).

the prometheus metrics includes a reason, including potential tls alerts, if
remote smtp clients would send those (openssl s_client -starttls does).

inspired by issue #237, where incoming connections were aborted by remote. such
errors would show up as "eof" in the metrics.
2024-11-29 12:43:21 +01:00
Mechiel Lukkien
09e7ddba9e
web apps: add autocomplete attribute for usernames and passwords
hinted at by chromium developer console
2024-11-29 10:40:22 +01:00
Mechiel Lukkien
96d86ad6f1
add ability to include custom css & js in web interface (webmail, webaccount, webadmin), and use css variables in webmail for easier customization
if files {webmail,webaccount,webadmin}.{css,js} exist in the configdir (where
the mox.conf file lives), their contents are included in the web apps.

the webmail now uses css variables, mostly for colors. so you can write a
custom webmail.css that changes the variables, e.g.:

	:root {
		--color: blue
	}

you can also look at css class names and override their styles.

in the future, we may want to make some css variables configurable in the
per-user settings in the webmail. should reduce the number of variables first.

any custom javascript is loaded first. if it defines a global function
"moxBeforeDisplay", that is called each time a page loads (after
authentication) with the DOM element of the page content as parameter. the
webmail is a single persistent page. this can be used to make some changes to
the DOM, e.g. inserting some elements. we'll have to see how well this works in
practice. perhaps some patterns emerge (e.g. adding a logo), and we can make
those use-cases easier to achieve.

helps partially with issue #114, and based on questions from laura-lilly on
matrix.
2024-11-29 10:17:07 +01:00
Mechiel Lukkien
9e8c8ca583
webmail: fix dragging the corner of the compose popup when it's on top of a message view with an iframe (for an html message)
the pointer events for moving the mouse would be consumed by the iframe. that
broke resizing of the compose popup.  we now disable pointerevents on the main
ui when we are dragging the corner of the compose popup.

this is similar to an earlier change about the draggable split bar between the
message list and the message view (when showing an html message).
2024-11-28 18:36:58 +01:00
Mechiel Lukkien
1f604c6a3d
webmail: when marking message as unread, also clear its (non)junk flags 2024-11-28 18:24:03 +01:00
Mechiel Lukkien
ee48cf0dfd
webmail: fix using the compose window/popup after saving a draft message failed
we kept the "save draft" promise, and would wait for it again for other
operations (eg close, save again, send), which wouldn't make progress.

can easily be reproduced by saving a message with a control character in an
address or the subject. saving the draft will fail.

for issue #256 by ally9335, thanks for reporting
2024-11-28 17:24:58 +01:00
Mechiel Lukkien
bd693805fd
webmail: tweak color for label about encrypted/signed messages
it wasn't very readable, probably since the change that introduced dark mode.
2024-11-28 16:46:24 +01:00
Mechiel Lukkien
d7f057709f
include goversion used to compile mox in the mox version 2024-11-28 16:28:05 +01:00
Mechiel Lukkien
636bb91df6
webaccount: tweak text about opening apple mobileconfig profile files, it has gotten harder to use in ios18
since ios18, downloaded files don't go immediately to the settings (which is
somewhat understandable given potential for abuse), but go to the Files app.
opening them in the Files app then adds them to the settings where they can be
installed.
2024-11-28 16:06:20 +01:00
Mechiel Lukkien
01deecb684
smtpserver: log an error message at debug level when we cannot parse a message for the smtputf8 check
instead of not logging any message. this should make it easier to debug.

based on delivery issue due to smtputf8 seen by wneessen.
2024-11-25 13:25:12 +01:00
Mechiel Lukkien
7f5e1087d4
admin: better handling of disabled mta-sts during self-check
if admin has disabled mta-sts for a domain, we still check for records &
policies, but won't mark it as error when they don't exist. we do now keep
warning that mta-sts isn't enabled, otherwise we would start showing a green
"ok".

this also fixes the mta-sts code returning ErrNoPolicy when mtasts.<domain>
doesn't exist. the dns lookup is done with the reguler "net" package dns lookup
code, not through adns, so we look for two types of DNSError's.

noticed a while ago when testing with MTA-STS while debugging TLS connection
issues with MS.
2024-11-24 13:30:29 +01:00
Mechiel Lukkien
726c0931f7
admin: in self-check for spf records against our ip's, don't try checking the unspecified addresses (0.0.0.0 and ::), and warn if there are no explicitly configured ips
based on question by spectral369 on #mox on matrix
2024-11-24 12:41:00 +01:00
Matt Fellenz
501f594a0a
Split paste into addr field by commas 2024-11-23 15:11:57 +01:00
Mechiel Lukkien
32d4e9a14c
log when mox root process cannot forward signals to unprivileged child
and give the mox.service permissions to send such signals.
2024-11-21 21:59:36 +01:00
Mechiel Lukkien
3d4cd00430
when opening an account by email address, such as during login attempts, and address is an alias, fail with proper error "no such credentials" instead of with error "no such account", which printing a stack trace
was encountered during smtp session. but could also happen for imapserver and
webmail.

in smtpserver, we now log error messages for smtp errors that cause us to print
a stack trace. would have made logging output more helpful (without having to
turn on trace-level logging).

hopefully solves issue #238 by mwyvr, thanks for reporting!
2024-11-10 23:20:17 +01:00
Mechiel Lukkien
0e338b0530
for aliases, enable "public posting" by default when creating an alias
and explain in more detail what it means in the webadmin interface.
will hopefully bring less confusion.

for issue #244 by exander77, thanks for reporting
2024-11-10 22:25:08 +01:00
Mechiel Lukkien
c13f1814fc
also use "SRV 0 0 port ." in webadmin pages
for issue #240, thanks bwbroersma for reporting and patch
2024-11-10 22:24:47 +01:00
Benjamin W. Broersma
355488028d
More RFC compliant SRV service not available
Fix #240.
2024-11-07 15:01:02 +01:00
Mechiel Lukkien
68c130f60e
add v0.0.13 to website 2024-11-06 23:20:44 +01:00
Mechiel Lukkien
22c8911bf3
disable tls session tickets to workaround deliverability issues with incoming email from microsoft
for issue #237
2024-11-06 10:19:23 +01:00
startup-001-steve
76f7b9ebf6
added link to Matrix Chat Room
and make matrix.to url a link and wrap text
2024-11-01 12:11:10 +01:00
Mechiel Lukkien
8fa197b19d
imapserver: for the "bodystructure" fetch response item, add the content-type parameters for multiparts so clients will get the mime boundary without having to parse the message themselves
"bodystructure" is like "body", but bodystructure allows returning more
information. we chose not to do that, initially because it was easier to
implement, and more recently because we can't easily return the additional
content-md5 field for leaf parts (since we don't have it in parsed form). but
now we just return the extended form for multiparts, and non-extended form for
leaf parts. likely no one would be looking for any content-md5-value for leaf
parts anyway. knowing the boundary is much more likely to be useful.

for issue #217 by danieleggert, thanks for reporting!
2024-11-01 11:28:25 +01:00
Mechiel Lukkien
598c5ea6ac
smtpserver: when logging recipients, actually show something about the recipient
before this change, we were logging an empty string, which turned into "[]",
looking like an empty array. misleading and unhelpful.

this is fixed by making struct fields on type recipient "exported" so they can
get logged, and by changing the logging code to log nested
struct/pointer/interface fields if we would otherwise wouldn't log anything
(when only logging more basic data types).

we'll now get log lines like:

	l=info m="deliver attempt to unknown user(s)" pkg=smtpserver recipients="[addr=bogus@test.example]"

for issue #232 by snabb, thanks for reporting!
2024-11-01 10:38:31 +01:00
Mechiel Lukkien
879477a01f
webmail: during "send and archive", don't fail with error message when message that is being responded to is already in archive folder
before this change, when archiving, we would move all messages from the thread
that are in the same mailbox as that of the response message to the archive
mailbox. so if the message that was being responsed to was already in the
archive mailbox, the message would be moved from archive mailbox to archive
mailbox, resulting in an error.

with this change, when archiving, we move the thread messages that are in the
same mailbox as is currently open (independent of the mailbox the message lives
in, a common situation in the threading view). if there is no open mailbox
(search results), we still use the mailbox of the message being responded to as
reference.

with this new approach, we won't get errors moving a message to an archive
mailbox when it's already there. well, you can still get that error, but then
you've got the archive mailbox open, or you're in a search result and
responding to an archived message. the error should at least help understand
that nothing is happening.

we are only moving the messages from one active/reference mailbox because we
don't want to move messages from the thread that are in the Sent mailbox, and
we also don't want to move duplicate messages (cross-posts to mailing lists)
that are in other mailboxes. moving only the messages from the current active
mailbox seems safe, and should do what is what users would expect most of the
time.

for issue #233 by mattfbacon, thanks for reporting!
2024-11-01 09:39:40 +01:00
Mechiel Lukkien
04305722a7
webmail: if we don't have loaded account settings yet, abort loading the popup after showing an error that the settings aren't available yet
missing returning/throwing error.

based on screenshot with unhandled js error in issue #218 by mgkirs
2024-10-10 14:29:52 +02:00
Mechiel Lukkien
0fbf24160c
add a handler for the acme http-01 validiation mechanism to all plain http (non-tls) webservers (ports), not only to the one listening on port 80
because this mechanism is most needed behind a reverse proxy, where acme
tls-alpn-01 won't work (because the reverse proxy won't pass on the alpn
extensions). if that's the case, there is obviously a webserver on port 443.
and it likely also running on port 80. so before this change, if tls-alpn-01
isn't available, http-01 also wasn't available, leaving no validation
mechanisms.

for issue #218 by mgkirs, thanks for reporting and details. hope this helps.
2024-10-10 14:04:13 +02:00
Mechiel Lukkien
354b9f4d98
tweak docs for release process 2024-10-06 13:07:11 +02:00
Mechiel Lukkien
bd842d3ff5
add upcoming release to website, and rotate apidiff 2024-10-06 12:48:56 +02:00
Mechiel Lukkien
5699686870
generate apidiff 2024-10-06 10:46:50 +02:00
Mechiel Lukkien
fdc0560ac4
for messages retired from the delivery queue, set "success" field properly, and include the smtp code/enhanced code on success too (not only on failure)
noticed some time ago when looking at my retired messages queue.
2024-10-05 11:06:42 +02:00
Mechiel Lukkien
fb65ec0676
webmail: fix loading a "view" (messages in a mailbox) when the "initial" message cannot be parsed
when we send a list of messages from the mox backend to the js frontend, we
include a parsed form of the "initial" message: the one we immediately show,
typically the top-most (unread) message. however, if that message could not be
parsed (due to invalid header syntax), we would fail the entire operation of
loading the view.

with this change, we simply don't return a parsed form of an initial message if
we cannot parse it. that will cause the webmail frontend to not select &
display a message immediately. if you then try to open the message, you'll
still get an error message as before. but at least the view has been loaded,
and you can open the raw message to inspect the contents.

for issue #219 by wneessen
2024-10-05 09:50:40 +02:00
Mechiel Lukkien
5d97bf198a
add support for parsing the imap "bodystructure" extensible form
not generating it yet from imapserver because we don't have content-md5
available. we could send "nil" instead of any actual content-md5 header (and
probably no contemporary messages include a content-md5 header), but it would
not be correct. if no known clients have problems in practice with absent
extensible data, it's better to just leave the bodystructure as is, with
extensible data.

for issue #217 by danieleggert
2024-10-04 22:55:43 +02:00
Mechiel Lukkien
81c179bb4c
fix embarrasing bug in checking if string is ascii
result reversed

for issue #179 and issue #157
2024-10-04 20:05:28 +02:00
Mechiel Lukkien
edb6e8d15c
webmail: fix displaying a message in separate window if there was no known viewmode (text or html or html with externals)
we were sending a zero value for ViewMode, which the frontend js rejected
during parsing.

noticed during testing.
2024-10-04 16:37:32 +02:00
Mechiel Lukkien
32b549b260
add more details to x-mox-reason message header added during delivery, for understanding why a message is accepted/rejected
we add various information while analysing an incoming message. like
dkim/spf/ip reputation. and content-based junk filter threshold/result and
ham/spam words used.

for issue #179 by Fell and #157 by mattfbacon
2024-10-04 16:01:30 +02:00
Mechiel Lukkien
98d0ff22bb
update to latest dependencies 2024-10-04 09:44:59 +02:00
Mechiel Lukkien
9a4fa8633f
add missing file from previous commit 2024-10-04 09:34:37 +02:00
Mechiel Lukkien
8f7fc3773b
add subcommand that prints licenses, and link to licenses from the webadmin/webaccount/webmail interfaces 2024-10-04 09:31:31 +02:00
Mechiel Lukkien
7d3f307156
acme port config option, explain why using a https reverse proxy will not work for acme tls-alpn-01 verification
related to #218 by mgkirs
2024-10-03 21:16:19 +02:00
Mechiel Lukkien
7ecc3f68ce
for the smtp login method, use challenges "Username:" and "Password:" as attempt to improve interoperability
there is only an internet-draft about the required behaviour. it says clients
should ignore the strings. some clients do check the string. most servers
appear to use "Username:" and "Password:" as challenge. we'll follow them,
hoping to improve interoperability.

for issue #223 by gdunstone, and with analysis from wneessen of go-mail.
thanks!
2024-10-03 20:29:40 +02:00
Mechiel Lukkien
bbc419c6ab
in webadmin when managing aliases, mention an alias member won't receive a message if the member address is in the message From header
this is a typical case if you made an alias to test how it works, with your
account. we may have to make this behaviour optional in the future.

for issue #220 by wneessen, thanks for reporting!
2024-10-03 20:20:14 +02:00
Mechiel Lukkien
c7315cb72d
handle scram errors more gracefully, not aborting the connection
for some errors during the scram authentication protocol, we would treat some
errors that a client connection could induce as server errors, printing a stack
trace and aborting the connection.

this change recognizes those errors and sends regular "authentication failed"
or "protocol error" error messages to the client.

for issue #222 by wneessen, thanks for reporting
2024-10-03 15:18:09 +02:00
Mechiel Lukkien
b0c4b09010
add "RcptTo" to webapi MessageGet result
otherwise, if the recipient was a bcc, there's no good way to see why the
message was received.

incoming webhooks already have this rcptto field, but that's not always the
moment you want to process it.

for mattanja on matrix, thanks for reporting!
2024-09-30 10:43:48 +02:00
Mechiel Lukkien
a7bdc41cd4
reject attempts at starttls for smtp & imap when no tls config is present
we didn't announce starttls as capability, but clients can still try them. we
would try to do a handshake with a nil certificate, which would cause a
goroutine panic (which is handled gracefully, shutting down the connection).

found with code that was doing starttls unconditionally.
2024-09-15 17:18:50 +02:00
Mechiel Lukkien
0977b7a6d3
get rid of some more gnulinuxisms
to get builds on openbsd going
2024-09-14 20:53:21 +02:00
Mechiel Lukkien
661e77c622
remove linuxism
should make build get further on openbsd
2024-09-14 14:22:39 +02:00
Mechiel Lukkien
b7ba0482ba
don't run install scripts when installing js dependencies 2024-09-08 09:49:24 +02:00
Mechiel Lukkien
594182aae5
webmail: rename query string param "token" to "singleUseToken" to be less scary in access logs
these singleusetokens can be redeemed once. so when you see it in the logs, it
can't be used again. they are short-lived anyway.

this change should help prevent me periodically investigating token handling...
2024-08-23 15:08:27 +02:00
Mechiel Lukkien
a977082b89
when login sessions to admin/account/webmail interfaces expiry or are no longer valid, explain the behaviour in the message
before, we would just say "session expired". now we say "session expired (after
12 hours inactivity)" (for admin) or "session expired (after 24 hours
inactivity)" for account/webmail. for unknown sessions in the admin interface,
we also explain that server restarts and 10 more new sessions can be the
reason.

for issue #202 by ally9335
2024-08-23 14:48:45 +02:00
Mechiel Lukkien
dfe4a54e0b
webmail: when a ui element (eg button) is disabled, make that clear with styles
since we have more of our own styling (probably since dark mode), we weren't
indicating anymore that a button was disabled. this actually only applies to
the button for the current mailbox of a message, when attempting to move it.

we now don't show any hover effects in that case, and we show the button
semitransparent.
2024-08-23 14:28:05 +02:00
Mechiel Lukkien
b77f44ab58
webmail: add setting to show html version of a message by default, instead of text version
related to issue #196 by GildedHonour
2024-08-23 14:02:55 +02:00
Mechiel Lukkien
fe9afb40bc
webmail: for html-only messages, ensure the "html" button is shown as active
instead of both "html" and "html with external resources" being shown as inactive.
2024-08-23 13:39:16 +02:00
Mechiel Lukkien
a485df830d
webapi: minor tweaks in docs 2024-08-23 12:12:13 +02:00
Mechiel Lukkien
6c488ead0b
webapi: implement adding "alternative files" to messages sent with the Send method
with new field "AlternativeFiles" in the JSON body, or with "alternativefile" form file uploads.

can be used if there is a (full) alternative representation (alternative to
text and/or html part), like a calendar item, or PDF file.

for issue #188 by morki
2024-08-23 12:00:25 +02:00
Mechiel Lukkien
62bd2f4427
for incoming smtp deliveries with starttls, use cert of hostname if sni hostname is unknown
instead of failing the connection because no certificates are available.

this may improve interoperability. perhaps the remote smtp client that's doing
the delivery will decide they do like the tls cert for our (mx) hostname after
all.

this only applies to incoming smtp deliveries. for other tls connections
(https, imaps/submissions and imap/submission with starttls) we still cause
connections for unknown sni hostnames to fail. if case no sni was present, we
were already falling back to a cert for the (listener/mx) hostname, that
behaviour hasn't changed.

for issue #206 by RobSlgm
2024-08-23 11:04:21 +02:00
Mechiel Lukkien
7e7f6d48f1
install latest versions of staticcheck & shadow
they tend to break each 6 months with a new go toolchain.
listing fixed versions probably causes more failures than always using the
latest versions.
2024-08-22 22:06:30 +02:00
Mechiel Lukkien
17346d6def
smtpclient: handle server closing connection after writing its response to RCPT TO
if icloud.com has your ip blocklisted, it will close the smtp connection after
writing a response to RCPT TO, before writing a response to a pipelined DATA
command. this is similar to the case (already handled) where a mail server
would close the connection after a response to MAIL FROM when pipelined.

we now recognize this situation (unexpected EOF before we get a response to
DATA, with all RCPT TO's failed), and treat the last response to RCPT TO as the
result.

for issue #198 by soheilpro, thanks for reporting and sending an smtpclient
trace that showed the behaviour.
2024-08-22 21:59:53 +02:00
Mechiel Lukkien
c16162eebc
update to golang.org/x/{crypto,net,text,sync,tools}@latest 2024-08-22 20:45:35 +02:00
Mechiel Lukkien
09b13ed4d5
update to golang.org/x/mod@latest 2024-08-22 20:41:06 +02:00
Mechiel Lukkien
e7e023c6d0
update dependency golang.org/x/sys to latest 2024-08-22 20:39:41 +02:00
Mechiel Lukkien
5678b03324
recognize more charsets than utf-8/iso-8859-1/us-ascii when parsing message headers with address
as they occur in From/To headers, for example: "From:
=?iso-8859-2?Q?Krist=FDna?= <k@example.com>".  we are using net/mail to parse
such headers. most address-parsing functions in that package will only decode
charsets utf-8, iso-8859-1 and us-ascii. we have to be careful to always use
net/mail.AddressParser with a WordDecoder that understands more that the
basics.

for issue #204 by morki, thanks for reporting!
2024-08-22 17:36:49 +02:00
Mechiel Lukkien
0bb4501472
update to latest bbolt (db library) v1.3.11
with a fix for releasing pages allocated during a transaction that was rolled
back.

also bumps required go version to go1.22
2024-08-22 16:22:09 +02:00
Mechiel Lukkien
016fde8d78
fix parsing message headers with addresses that need double quotes
we are using Go's net/mail to parse message headers. it can parse addresses,
and properly decodes email addresses with double quotes (e.g. " "@example.com).
however, it gives us an address without the double quotes in the localpart,
effectively an invalid address. we now have a workaround to parse such
not-quite-addresses.

for issue #199 reported by gene-hightower, thanks for reporting!
2024-08-22 16:03:52 +02:00
Mechiel Lukkien
79b641cdc6
webmail: remove todo for vi editing mode in textarea
users should install a plugin.
i wrote https://addons.mozilla.org/en-US/firefox/addon/vi-editing-mode/, seems
good enough for now.
2024-08-19 15:55:32 +02:00
Mechiel Lukkien
2c003991bb
webmail: put attached files before inline files
some emails have text and html versions. the html can have several logo images.
and there may be a pdf attached. when gathering attachments to show in webmail,
the pdf would come last. it could happen the logo images would get a link to
click, and the pdf would be behind the "more ..." button. by putting
"multipart/mixed" files before the "multipart/related" in the list, it's more
likely that useful files can be clicked immediately, and unimportant logo files
are behind the "more"-button.
2024-08-05 12:10:10 +02:00
Mechiel Lukkien
0a4999f33e
webmail: improve dragging with mouse events over the message iframe
before, the iframe was consuming the mouse events, preventing the dragging to
the right from working properly. the workaround was to drag over the area with
the header, above the message iframe.

with this change, we disable pointer events over the entire right area, which
includes the iframe.
2024-08-03 14:49:38 +02:00
Mechiel Lukkien
aead738836
attempt at improving interoperability of with outlook 365 using the smtp "login" sasl auth mechanism
by sending the (encoded) string "User Name" as mentioned by the internet-draft,
https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00#section-2.1

that document says clients should ignore the challenge (which is why were were
not doing any effort and sending an empty challenge). but it also says some
clients require the challenge "Username:" instead of "User Name", implying that
it's important to not send an empty challenge. we can't send both challenges
though...

for issue #51
2024-07-18 21:17:33 +02:00
Mechiel Lukkien
c629ae26af
don't prevent the html pages to load a favicon, and provide one by default
for issue #186 by morki, thanks for reporting and providing sample favicons.

generated by the mentioned generator at favicon.io, with the ubuntu font and a
fuchsia-like color.

the favicon is served for listeners/domains that have the
admin/account/webmail/webapi endpoints enabled, i.e. user-facing. the mta-sts,
autoconfig, etc urls don't serve the favicon.

admins can create webhandler routes to serve another favicon. these webhandler
routes are evaluted before the favicon route (a "service handler").
2024-07-08 21:58:10 +02:00
KiekerJan
151bd1a9c0 Set syslog facility to mail 2024-07-01 12:12:39 +02:00
Mechiel Lukkien
7e54280a9d
show the same spf record for a domain in the dnsrecords and dnscheck output/pages
before, the suggested records would show "v=spf1 mx ~all", while the dnscheck
page would suggest "v=spf1 ip4:... ip6:... -all".

the two places now show the same record: explicitly listing the configured ip's
(so the common case of a valid message is fast and doesn't require lookups of
mx hosts and their addresses), but still including "mx" (may prevent issues
while migrating to new ips in the future and doesn't hurt for legit messages),
and "~all" (for compatibility with some old systems that don't look at
dkim/dmarc when they evaluate spf and reach "-all")

based on #176 created by rdelaage, with record mismatch spotted by RobSlgm,
thanks!
2024-06-28 14:50:39 +02:00
Mechiel Lukkien
367e968199
fix parsing Authentication-Results header with a "reason=..." part
noticed in gopherwatch logging
2024-06-28 10:39:46 +02:00
Mechiel Lukkien
73373a19c1
in dnscheck, warn when dane is not configured (through static host keys), instead of showing "OK"
if no host keys are configured, show as warning (yellow) that dane isn't
configured, and show instructions to enable it.

for issue #185 by morki, thanks for reporting!
2024-06-27 15:57:04 +02:00
Mechiel Lukkien
e350af7eed
during dnscheck, if srv accountconfig record with just a dot, for a non-existent service, is missing, show as warning instead of error
the suggested dns records mention that these records are optional, but the
dnscheck makes it look serious. not helpful.

also remove unneeded whitespace in list of errors/warnings.

for issue #184 by morki, thanks for reporting!
2024-06-27 15:12:52 +02:00
Mechiel Lukkien
beee03574a
mention that imported messages are not deduplicated
so importing twice can result in duplicates.

related to issue #180
2024-06-24 11:46:50 +02:00
Mechiel Lukkien
fdcd2eb0eb
webadmin: remove stray text "pre" in on the "required dns records" page 2024-06-24 10:22:42 +02:00
Mechiel Lukkien
9bab3124f6
show correct host tlsrpt record in dns selfcheck, and make all suggested dns records absolute
the host tlsrpt record implied it was for the domain, but should have been for
the mail host.

some dns records were absolute, others weren't. now they all are for
consistency.

for issue #182 by mdavids, thanks for reporting!
2024-06-22 11:46:12 +02:00
Mechiel Lukkien
ac3596a7d7
try fixing race in tests of ctl socket
there were a few test failures on the github runners. i can't reproduce it
locally. but i can see how they are happening: a gorouting running servectlcmd
could still be doing cleanup (removing files) while a next ctl command was
being run. with this change, we wait for servectlcmd to be done before starting
on a next test.
2024-06-10 23:07:01 +02:00
Mechiel Lukkien
8254e9ce66
webmail: only show "edit" button on drafts, and similar for "e" shortcut
always showing the "edit" button was a bug.
2024-06-10 20:19:17 +02:00
Mechiel Lukkien
a4f7e71457
webmail: ensure white background when viewing attachments, for the black text of plain text attachments
otherwise, in dark mode, the plain text iframe content would be black text on
the white background of the iframe as set by webmail. i can't find a way to set
the content text on the iframe that contains it.
2024-06-10 20:11:26 +02:00
Mechiel Lukkien
f56b04805b
make tests pass with "go test -count n" with n > 1
by closing initialized resources during tests.
2024-06-10 18:18:20 +02:00
Mechiel Lukkien
dde2258f69
update to latest sconf, for improved error messages for mixed tab/space indenting in config files
based on chat with niklas/broitzer
2024-06-10 18:02:47 +02:00
Mechiel Lukkien
aef99a72d8
imapserver: prevent unbounded memory allocations when handling a command
some commands, like search, can specify any number of literals, of arbitrary
size.  we already limited individual literals to 100kb. but you could specify
many of them, causing unbounded memory consumption. this change adds a limit of
1000 literals in a command, and a limit of 1mb of total combined memory for
literals. once the limits are exceeded, a TOOBIG error code is returned.

unbounded memory use could only be triggered on authenticated connections.

this addresses the same issue as CVE-2024-34055 for cyrus-imap, by damian
poddebniak.
2024-06-10 15:00:18 +02:00
Mechiel Lukkien
614576e409
improve http request handling for internal services and multiple domains
per listener, you could enable the admin/account/webmail/webapi handlers. but
that would serve those services on their configured paths (/admin/, /,
/webmail/, /webapi/) on all domains mox would be webserving, including any
non-mail domains. so your www.example/admin/ would be serving the admin web
interface, with no way to disabled that.

with this change, the admin interface is only served on requests to (based on
Host header):
- ip addresses
- the listener host name (explicitly configured in the listener, with fallback
  to global hostname)
- "localhost" (for ssh tunnel/forwarding scenario's)

the account/webmail/webapi interfaces are served on the same domains as the
admin interface, and additionally:
- the client settings domains, as optionally configured in each Domain in
  domains.conf. typically "mail.<yourdomain>".

this means the internal services are no longer served on other domains
configured in the webserver, e.g. www.example.org/admin/ will not be handled
specially.

the order of evaluation of routes/services is also changed:
before this change, the internal handlers would always be evaluated first.
with this change, only the system handlers for
MTA-STS/autoconfig/ACME-validation will be evaluated first. then the webserver
handlers. and finally the internal services (admin/account/webmail/webapi).
this allows an admin to configure overrides for some of the domains (per
hostname-matching rules explained above) that would normally serve these
services.

webserver handlers can now be configured that pass the request to an internal
service: in addition to the existing static/redirect/forward config options,
there is now an "internal" config option, naming the service
(admin/account/webmail/webapi) for handling the request. this allows enabling
the internal services on custom domains.

for issue #160 by TragicLifeHu, thanks for reporting!
2024-05-11 11:13:14 +02:00
Mechiel Lukkien
9152384fd3
use debug logging in tests
by setting the loglevel to debug in package mlog.
we restore the "info" logging in main.
except for "mox localserve", which still sets debug by default.
2024-05-10 15:51:48 +02:00
Mechiel Lukkien
bf8cfd9724
add debug logging about bstore db schema upgrades
bstore was updated to v0.0.6 to add this logging.
this simplifies some of the db-handling code in mtastsdb,tlsrptdb,dmarcdb. we
now call the package-level Init() and Close() in all tests properly.
2024-05-10 14:44:37 +02:00
Mechiel Lukkien
3e4cce826e
webaccount: change xcheckf to handle mox.ErrConfig as user error
like in webadmin
2024-05-09 22:45:44 +02:00
Mechiel Lukkien
3f000fd4e0
make most fields of junk filter configurable by account itself
finally remove the message saying that not all config options can be configured
through the web interface.
2024-05-09 22:45:16 +02:00
Mechiel Lukkien
ebb8ad06b5
use shorter smtp.NewAddress() instead of smtp.Address{...} 2024-05-09 21:26:22 +02:00
Mechiel Lukkien
1179d9d80a
webmail: when opening message in new tab, set document title to subject, message from address(es) and id of message 2024-05-09 21:19:58 +02:00
Mechiel Lukkien
a06a4de5ec
for ctl commands, read all lines before processing, to prevent out of sync protocol when handling errors.
the protocol is often: read one or more lines. only then return error. if we
would return an error after reading 1 line, parsing it and failing, the writer
(client connecting) may be busy writing more lines, not reading an error
response yet.
2024-05-09 21:11:20 +02:00
Mechiel Lukkien
1a0a396713
webmail: in list of From address to use in compose window, don't add the catchall address
it was even selected by default.
2024-05-09 20:55:03 +02:00
Mechiel Lukkien
1fc8f165f7
clarify behaviour of backup command
from RobSlgm, issue #172
2024-05-09 17:48:22 +02:00
Mechiel Lukkien
83004bb18e
give more helpful pointers for dns-related settings
in quickstart, add troubleshooting hints.
in suggested dns records, explain the multiline long dkim record should
probably be converted into a single string.

the quickstart output is quite long already. i'm hoping for a "mox setup" in
the future where we help a user step-by-step to a fully working system. we'll
have more space to prevent hints and check the settings after a user made
changes. it's on the roadmap.

based on issues #158 and #164, thanks vipas84 and RobSlgm for reporting and
discussion.
2024-05-09 17:28:29 +02:00
Mechiel Lukkien
30ac690c8f
when removing account, remove its data directory instead of leaving it around
recreating the account would resurface the old messages, certainly not what you'ld expect.
it's about time to just remove the files. we do ask admins to confirm that when
removing through admin interface. it's also in the "mox config account rm" help
output now.

for issue #162 by RobSlgm with feedback from x8x, thanks!
2024-05-09 16:30:11 +02:00
Mechiel Lukkien
a2c9cfc55b
webadmin: don't show runtime typecheck error for invalid values in dmarc and tls reports
several fields in dmarc and tls reports have known string values. we have a Go
string type for them. sherpats (through sherpadoc) turns those strings into
typescript enums, and sherpats generates runtime-typechecking code (to enforce
correct types for incoming json, to prevent failing deeper in the code when we
get invalid data (much harder to debug)). the Go not-really-enum types allow
other values, and real-world reports have unknown/unspecified/invalid values.
this uses the sherpadoc -rename flag to turn those enums into regular untyped
strings, so sherpats doesn't generate enum-enforcing runtime type checking
code.

this required an update to sherpadoc, to properly handling renaming a type to a
basic type instead of another named type.

for issue #161 by RobSlgm, thanks for reporting!
2024-05-09 15:58:14 +02:00
Mechiel Lukkien
44a6927379
add hint about systemd ReadWritePaths if hardlinking fails on linux due to cross-device link
may help admin figure out more easily how to work around this.

for issue #170 by rdelaage
2024-05-09 14:25:24 +02:00
Mechiel Lukkien
4d28a02621
webmail: better save/close/cancel buttons in compose window
- keep them on the right side of the window (more important now that we can resize)
- merge the close & cancel buttons into a close button, with a popup asking what to do for changes not saved as draft.
2024-05-09 11:46:00 +02:00
Mechiel Lukkien
76aa96ab6f
webadmin: consistent pattern for client api calls wrapped in async/await
adding await in the closure. makes no functional different. but let's stick to one form.
2024-05-09 11:31:04 +02:00
Mechiel Lukkien
98ce133203
update to latest adns with fix for endless loop for incoming corrupt packets 2024-05-09 11:30:42 +02:00
Mechiel Lukkien
09ee89d5c8
update roadmap 2024-05-09 10:51:11 +02:00
Mechiel Lukkien
72be3e8423
webadmin: propagate error when quota size cannot be parsed, improve parsing and hint in error message
- the error wasn't caught because the parseInt() call wasn't evaluated inside the promise.
- we no longer require that the input (e.g. 2G) is the same as how we would format it (2g).
- tooltips and error message should now steer people to using these units.

feedback from pmarini-nc in #115, thanks!
2024-05-09 10:46:18 +02:00
Mechiel Lukkien
db3e44913c
update to latest bbolt
with two changes, both not resulting in different behaviour for us.
2024-05-09 10:32:27 +02:00
Sebastian Haas
587beb75b1 fix typo in SRV validation message
_.tcp => ._tcp
2024-05-07 07:47:26 +02:00
Mechiel Lukkien
a16c08681b
webmail: change many inline styles to using css classes, and add dark mode
this started with looking into the dark mode of PR #163 by mattfbacon. it's a
very good solution, especially for the amount of code. while looking into dark
mode, some common problems with inverting colors are:
- box-shadow start "glowing" which isn't great. likewise, semitransparent
  layers would become brighter, not darker.
- while popups/overlays in light mode just stay the same white, in dark mode
  they should become lighter than the regular content because box shadows don't
  give enough contrast in dark mode.

while looking at adding explicit styles for dark mode, it turns out that's
easier when we work more with css rules/classes instead of inline styles (so we
can use the @media rule).

so we now also create css rules instead of working with inline styles a lot.
benefits:
- creating css rules is useful for items that repeat. they'll have a single css
  class. changing a style on a css class is now reflected in all elements of that
  kind (with that class)
- css class names are helpful when inspecting the DOM while developing: they
  typically describe the function of the element.

most css classes are defined near where they are used, often while making the
element using the class (the css rule is created on first use).

this changes moves colors used for styling to a single place in webmail/lib.ts.
each property can get two values: one for regular/light mode, one for dark mode.
that should prevent forgetting one of them and makes it easy to configure both.
this change sets colors for the dark mode. i think the popups look better than
in PR #163, but in other ways it may be worse. this is a start, we can tweak
the styling.

if we can reduce the number of needed colors some more, we could make them
configurable in the webmail settings in the future. so this is also a step
towards making the ui looks configurable as discussed in issue #107.
2024-05-06 09:13:50 +02:00
Mechiel Lukkien
195c57f06e
update website with latest release v0.0.11 2024-04-30 20:54:32 +02:00
Mechiel Lukkien
7ba18609cd
rotate apidiff/next.txt before release 2024-04-30 20:52:50 +02:00
Mechiel Lukkien
78a59b3476
webadmin: after looking up cid, show it
seems like the useful line of that functionality got lost...
2024-04-29 21:14:05 +02:00
Mechiel Lukkien
5f00f7662e
update readme and docs 2024-04-29 21:10:25 +02:00
Mechiel Lukkien
e34b2c3730
remove log.Print added for debugging 2024-04-29 21:09:41 +02:00
Mechiel Lukkien
b7ec84b80a
queue: when shutting down, wait for pending deliveries before signaling that shutdown is complete
also fixes flaky test, which is how i found it
2024-04-28 22:48:51 +02:00
Mechiel Lukkien
ff6cca1bf9
fix flaky test: close account before marking thread-upgrade as finished
store/threads_test.go opens an account, starts the threading upgrade, waits for
it to finish, runs some tests, and closes the account at the end, verifying all
references are gone. the "thread upgrade" goroutine has its own account
reference. it closes its account after having signaled completion of the
upgrade. in between that time, all checks from the tests could run, its account
closed and its no-more-account-references check would fail. the fix is
hopefully to mark the thread upgrade process finished after closing the
account. hard to verify, but this only happens very rarely.
2024-04-28 14:09:40 +02:00
Mechiel Lukkien
b3a693ee31
update to latest golang.org/x dependencies 2024-04-28 13:53:37 +02:00
Mechiel Lukkien
8cc795b2ec
in smtp submission, if a fromid is present in the mailfrom command, use it when queueing
it's the responsibility of the sender to use unique fromid's.
we do check if that's the case, and return an error if not.

also make it more clear that "unique smtp mail from addresses" map to the
"FromIDLoginAddresses" account config field.

based on feedback from cuu508 for #31, thanks!
2024-04-28 13:18:25 +02:00
Mechiel Lukkien
32cf6500bd
when removing an address, remove it as member from aliases
unless the address is the last member, then the admin must either remove the
alias first, or add new members. we don't want to accidentally remove an alias
address.

in the admin page for removing addresses, we warn the admin that the address
will be removed from any aliases.
2024-04-28 11:44:51 +02:00
Mechiel Lukkien
e2924af8d2
ensure senderaccount is always set for messages in queue
before, the smtpserver that queued a dsn would set an empty senderaccount,
which was interpreted in a few places as the globally configured postmaster
cacount. the empty senderaccount would be used by the smtpserver that queued a
dsn with null return path. we now set the postmaster account when we add a
message to the queue. more code in the queue pretty much needs a non-empty
senderaccount, such as the filters when listing, and the suppression list.
2024-04-28 11:03:47 +02:00
Mechiel Lukkien
6e7f15e0e4
smtpserver tests: use shared function to check expected smtp error codes 2024-04-24 21:00:20 +02:00
Mechiel Lukkien
f749eb2a05
use css white-space: pre-wrap for email addresses displayed
since email addresses can contain multiple consecutive spaces.
this is a valid address: "   "@localhost
and this is a different valid address: " "@localhost

webmail still todo
2024-04-24 20:37:56 +02:00
Mechiel Lukkien
fece75cfe7
automatically install typescript into ./node_modules if missing during build
simplifies process.
2024-04-24 19:48:01 +02:00
Mechiel Lukkien
d9f5625a89
regenerate apidiff, removal due to sherpadoc cleanup 2024-04-24 19:37:47 +02:00
Mechiel Lukkien
960a51242d
add aliases/lists: when sending to an alias, the message gets delivered to all members
the members must currently all be addresses of local accounts.

a message sent to an alias is accepted if at least one of the members accepts
it. if no members accepts it (e.g. due to bad reputation of sender), the
message is rejected.

if a message is submitted to both an alias addresses and to recipients that are
members of the alias in an smtp transaction, the message will be delivered to
such members only once.  the same applies if the address in the message
from-header is the address of a member: that member won't receive the message
(they sent it). this prevents duplicate messages.

aliases have three configuration options:
- PostPublic: whether anyone can send through the alias, or only members.
  members-only lists can be useful inside organizations for internal
  communication. public lists can be useful for support addresses.
- ListMembers: whether members can see the addresses of other members. this can
  be seen in the account web interface. in the future, we could export this in
  other ways, so clients can expand the list.
- AllowMsgFrom: whether messages can be sent through the alias with the alias
  address used in the message from-header. the webmail knows it can use that
  address, and will use it as from-address when replying to a message sent to
  that address.

ideas for the future:
- allow external addresses as members. still with some restrictions, such as
  requiring a valid dkim-signature so delivery has a chance to succeed. will
  also need configuration of an admin that can receive any bounces.
- allow specifying specific members who can sent through the list (instead of
  all members).

for github issue #57 by hmfaysal.
also relevant for #99 by naturalethic.
thanks to damir & marin from sartura for discussing requirements/features.
2024-04-24 19:15:30 +02:00
Mechiel Lukkien
1cf7477642
localserve: change queue to deliver to localserve smtp server
instead of skipping on any smtp and delivering messages to accounts.
we dial the ip of the smtp listener, which is localhost:1025 by default.

the smtp server now uses a mock dns resolver during spf & dkim verification for
hosted domains (localhost by default), so they should pass.

the advantage is that we get regular full smtp server behaviour for delivering
in localserve, including webhooks, and potential first-time sender delays
(though this is disabled by default now).

incoming deliveries now go through normal address resolution, where before we
would always deliver to mox@localhost. we still accept email for unknown
recipients to mox@localhost.

this will be useful upcoming alias/list functionality.

localserve will now generate a dkim key when creating a new config. existing
users may wish to reset (remove) their localserve directory, or add a dkim key.
2024-04-24 11:40:22 +02:00
Mechiel Lukkien
2bb4f78657
remove spurious empty line to fix build, and update roadmap 2024-04-22 14:32:50 +02:00
Mechiel Lukkien
bf5cfca6b9
webmail: add export functionality
per mailbox, or for all mailboxes, in maildir/mbox format, in tar/tgz/zip
archive or without archive format for single mbox, single or recursive. the
webaccount already had an option to export all mailboxes, it now looks similar
to the webmail version.
2024-04-22 13:41:40 +02:00
Mechiel Lukkien
a3f5fd26a6
webmail: less boilerplate code for api functions
open the account at the beginning of the api handler, and close accounts there too
2024-04-21 21:32:24 +02:00
Mechiel Lukkien
ed0c520562
webmail: single db transaction while fetching parsed message 2024-04-21 20:45:06 +02:00
Mechiel Lukkien
8ad32f9ede
improve docs about IPs and ipv4/ipv6 used for outgoing connections
based on feedback from alex on irc, thanks!
2024-04-21 17:22:00 +02:00
Mechiel Lukkien
884f5b5b3f
remove some old todo's from webmail 2024-04-21 17:18:00 +02:00
Mechiel Lukkien
6c0439cf7b
webmail: when moving a single message out of/to the inbox, ask if user wants to create a rule to automatically do that server-side for future deliveries
if the message has a list-id header, we assume this is a (mailing) list
message, and we require a dkim/spf-verified domain (we prefer the shortest that
is a suffix of the list-id value). the rule we would add will mark such
messages as from a mailing list, changing filtering rules on incoming messages
(not enforcing dmarc policies). messages will be matched on list-id header and
will only match if they have the same dkim/spf-verified domain.

if the message doesn't have a list-id header, we'll ask to match based on
"message from" address.

we don't ask the user in several cases:
- if the destination/source mailbox is a special-use mailbox (e.g.
  trash,archive,sent,junk; inbox isn't included)
- if the rule already exist (no point in adding it again).
- if the user said "no, not for this list-id/from-address" in the past.
- if the user said "no, not for messages moved to this mailbox" in the past.

we'll add the rule if the message was moved out of the inbox.
if the message was moved to the inbox, we check if there is a matching rule
that we can remove.

we now remember the "no" answers (for list-id, msg-from-addr and mailbox) in
the account database.

to implement the msgfrom rules, this adds support to rulesets for matching on
message "from" address. before, we could match on smtp from address (and other
fields). rulesets now also have a field for comments. webmail adds a note that
it created the rule, with the date.

manual editing of the rulesets is still in the webaccount page. this webmail
functionality is just a convenient way to add/remove common rules.
2024-04-21 17:14:08 +02:00
Mechiel Lukkien
71c0bd2dd1
for localserve delivery from queue, use the recipient address for finding delivery rules, not sender address 2024-04-21 15:07:50 +02:00
Mechiel Lukkien
0047f09e2b
webmail: new shadowed variables were detected by shadow since previous commit, prevent 2024-04-20 21:33:14 +02:00
Mechiel Lukkien
0f735a1710
webmail: remember per from-address whether we should show the text/html/html-with-external-resources version of a message 2024-04-20 21:25:52 +02:00
Mechiel Lukkien
3a58b2a1f4
webmail: show all images (inline and attachment) below the text part (for the text view, not for html view)
the attachment buttons for images get some opacity for the text view, to
indicate you don't have to open them explicitly.
2024-04-20 21:17:05 +02:00
Mechiel Lukkien
41a62de4d7
webmail: with 6 or more attachments, show the first 4, and a button to show the rest.
for issue #113
2024-04-20 17:53:32 +02:00
Mechiel Lukkien
9529ae0bd4
webmail: store composed message as draft until send, ask about unsaved changes when closing compose window 2024-04-20 17:38:25 +02:00
Mechiel Lukkien
e8bbaa451b
webmail: allow resizing of compose window
in top-left direction. keep textarea filling the height.
remember size in localstorage, only apply either width and/or height when
viewport width/height was the same as when the remembered width/height was set
(independently).

no visual indicator other than a cursor indicating resizability.
2024-04-20 10:26:54 +02:00
Mechiel Lukkien
5229d01601
webmail: for replies/forwards, add button "send and archive thread" next to the "send" button, and give it a control+shift+Enter shortcut
the regular send shortcut is control+Enter. the shift enables "archive thread".
there is no configuration option, you'll always get the button, but only for
reply/forward, not for new compose.

we may do "send and move thread to thrash", but let's wait until people want it.

for github issue #135 by mattfbacon
2024-04-19 21:17:42 +02:00
Mechiel Lukkien
b54e903f01
webmail: ctrl Backspace now removes an address input field if it is empty
instead of "ctrl -". i found ctrl backspace more intuitive.
2024-04-19 18:03:56 +02:00
Mechiel Lukkien
8a1d81c29a
webmail: show link to webaccount interface in top right
only if account web interface is enabled on the same listener and same http/https scheme.
2024-04-19 18:02:30 +02:00
Mechiel Lukkien
70adf353ee
webmail: add server-side stored settings, for signature, top/bottom reply and showing the security indications below address input fields
should solve #102
2024-04-19 18:02:24 +02:00
Mechiel Lukkien
3bbd7c7d9b
website: mention "mox localserve" as a good way to get a feeling for mox 2024-04-19 11:12:17 +02:00
Mechiel Lukkien
ec967ef321
use new sherpadoc rename mechanism to remove some typename stuttering
the stuttering was introduced to make the same type name declared in multiple
packages, and used in the admin sherpa api, unique. with sherpadoc's new
rename, we can make them unique when generating the api definition/docs, and
the Go code can use nicer names.
2024-04-19 10:51:24 +02:00
Mechiel Lukkien
962575f21b
mention webhook retry intervals in webhook docs
for github issue #31, feedback from cuu508
2024-04-19 10:33:28 +02:00
Mechiel Lukkien
e702f45d32
webadmin: make remaining domain settings configurable via admin web interface
for dmarc reporting address, tls reporting address, mtasts policy, dkim keys/selectors.

should make it easier for webadmin-using admins to discover these settings.

the webadmin interface is now on par with functionality you would set through
the configuration file, let's keep it that way.
2024-04-19 10:23:53 +02:00
Mechiel Lukkien
a69887bfab
webadmin: make routes configurable: globally, per domain, per account
this simplifies some of the code that makes modifications to the config file. a
few protected functions can make changes to the dynamic config, which webadmin
can use. instead of having separate functions in mox-/admin.go for each type of
change.

this also exports the parsed full dynamic config to webadmin, so we need fewer
functions for specific config fields too.
2024-04-18 11:14:24 +02:00
Mechiel Lukkien
baf4df55a6
make more account config fields configurable through web interface
so users can change it themselves, instead of requiring an admin to change the
settings.
2024-04-17 21:31:26 +02:00
Mechiel Lukkien
8bcce40c55
webmail: recognize multiple urls in List-Post addresses
there may be a http(s)-address, which we'll ignore. the mailto may come after
that. like in google groups.
2024-04-16 20:26:37 +02:00
Mechiel Lukkien
8654a1f901
with localserve, in queue, when "delivering" to the sender account, mark domain "localhost" as dkimverified
may be useful for testing, e.g. for rulesets to deliver messages to mailboxes other than Inbox.
2024-04-16 19:26:26 +02:00
Mechiel Lukkien
0a10283de0
show separate localpart and dropdown of domains instead of full email field when adding a new account (with initial email address) 2024-04-16 19:23:00 +02:00
Mechiel Lukkien
c9451d4d06
in webmail & webapisrv, store bcc header in sent messages
when sending a message with bcc's, prepend the bcc header to the message we
store in the sent folder. still not in the message we send to the recipients.
2024-04-16 17:57:46 +02:00
Mechiel Lukkien
abd098e8c0
in more tests, after closing accounts, check the last reference is indeed gone 2024-04-16 17:33:54 +02:00
Mechiel Lukkien
afc47c8108
if webauth login cookie is missing, and forwarding was configured, hint that reverse proxy may be stripping path
the cookies are set with a specific path, because the webadmin, webaccount and
webmail cookies can be on the same domain (this is the default). if the reverse
proxy strips the path while forwarding, the browser won't set the cookie and
the login attempt will fail.

based on github issue #151 from naturalethic
2024-04-16 16:06:31 +02:00
Mechiel Lukkien
daa88480cb
fix potential endless loop during queue msg/hook pagination when environment has TZ UTC, triggered by tests introduced in previous test
time.Now() returns a timestamp with timezone Local. if you marshal & unmarshal
it again, it'll get the Local timezone again. unless the local timezone is UTC.
then it will get the UTC timezone. the same time.Time but with explicit UTC
timezone vs explicit UTC-as-Local timezone are not the same when comparing with
==. so comparison should be done with time.Time.Equal, or comparison should be
done after having called .Local() on parsed timestamps (so the explicit UTC
timezone gets converted to the UTC-as-Local timezone). somewhat surprising that
time.Local isn't the same as time.UTC if TZ=/TZ=UTC. there are warnings
throughout the time package about handling of UTC.
2024-04-16 14:18:11 +02:00
Mechiel Lukkien
09fcc49223
add a webapi and webhooks for a simple http/json-based api
for applications to compose/send messages, receive delivery feedback, and
maintain suppression lists.

this is an alternative to applications using a library to compose messages,
submitting those messages using smtp, and monitoring a mailbox with imap for
DSNs, which can be processed into the equivalent of suppression lists. but you
need to know about all these standards/protocols and find libraries. by using
the webapi & webhooks, you just need a http & json library.

unfortunately, there is no standard for these kinds of api, so mox has made up
yet another one...

matching incoming DSNs about deliveries to original outgoing messages requires
keeping history of "retired" messages (delivered from the queue, either
successfully or failed). this can be enabled per account. history is also
useful for debugging deliveries. we now also keep history of each delivery
attempt, accessible while still in the queue, and kept when a message is
retired. the queue webadmin pages now also have pagination, to show potentially
large history.

a queue of webhook calls is now managed too. failures are retried similar to
message deliveries. webhooks can also be saved to the retired list after
completing. also configurable per account.

messages can be sent with a "unique smtp mail from" address. this can only be
used if the domain is configured with a localpart catchall separator such as
"+". when enabled, a queued message gets assigned a random "fromid", which is
added after the separator when sending. when DSNs are returned, they can be
related to previously sent messages based on this fromid. in the future, we can
implement matching on the "envid" used in the smtp dsn extension, or on the
"message-id" of the message. using a fromid can be triggered by authenticating
with a login email address that is configured as enabling fromid.

suppression lists are automatically managed per account. if a delivery attempt
results in certain smtp errors, the destination address is added to the
suppression list. future messages queued for that recipient will immediately
fail without a delivery attempt. suppression lists protect your mail server
reputation.

submitted messages can carry "extra" data through the queue and webhooks for
outgoing deliveries. through webapi as a json object, through smtp submission
as message headers of the form "x-mox-extra-<key>: value".

to make it easy to test webapi/webhooks locally, the "localserve" mode actually
puts messages in the queue. when it's time to deliver, it still won't do a full
delivery attempt, but just delivers to the sender account. unless the recipient
address has a special form, simulating a failure to deliver.

admins now have more control over the queue. "hold rules" can be added to mark
newly queued messages as "on hold", pausing delivery. rules can be about
certain sender or recipient domains/addresses, or apply to all messages pausing
the entire queue. also useful for (local) testing.

new config options have been introduced. they are editable through the admin
and/or account web interfaces.

the webapi http endpoints are enabled for newly generated configs with the
quickstart, and in localserve. existing configurations must explicitly enable
the webapi in mox.conf.

gopherwatch.org was created to dogfood this code. it initially used just the
compose/smtpclient/imapclient mox packages to send messages and process
delivery feedback. it will get a config option to use the mox webapi/webhooks
instead. the gopherwatch code to use webapi/webhook is smaller and simpler, and
developing that shaped development of the mox webapi/webhooks.

for issue #31 by cuu508
2024-04-15 21:49:02 +02:00
Mechiel Lukkien
8bec5ef7d4
also trigger use of smtputf8 for utf8 localpart in Reply-To header 2024-04-15 20:47:53 +02:00
Mechiel Lukkien
d014303617
use wlock when delivering message about new mox version 2024-04-15 20:40:16 +02:00
Mechiel Lukkien
b7ed035730
add godoc to metrics/ 2024-04-15 20:33:44 +02:00
Mechiel Lukkien
e1dbc07dba
fix harmless race where the same value is written to a tls config concurrently 2024-04-15 20:07:39 +02:00
Mechiel Lukkien
11eaa8cd1a
make imapserver faster like before again
in the precis password change before the previous release, the password used in
fuzzing wasn't correct, triggering sleeps due to botched protocols often, which
made the tests run much longer.
2024-04-14 17:41:36 +02:00
Mechiel Lukkien
12e6975aa7
return smtp response/error correctly in more cases 2024-04-14 17:28:00 +02:00
Mechiel Lukkien
4012b72d96
use type config.Account in sherpa api for better typing, and update to latest sherpa lib
typescript now knows the full types, not just "any" for account config.
inline structs previously in config.Account are given their own type definition
so sherpa can generate types.

also update to latest sherpa lib that knows about time.Duration, to be used soon.
2024-04-14 17:18:20 +02:00
Mechiel Lukkien
b7d6540d51
style nit: only take address of structs when passed on 2024-04-14 12:46:24 +02:00
Mechiel Lukkien
2a949f9f79
fix typo in smtp error code 2024-04-14 12:42:47 +02:00
Mechiel Lukkien
e585a4d180
don't fail to generate apidiff when packages are introduced 2024-04-14 12:38:58 +02:00
Mechiel Lukkien
4b459af4a8
add install as target, calling "go install"
convenient for local testing, i'm often running "mox localserve", often helpful
if it's the latest.
2024-04-14 12:37:52 +02:00
Mechiel Lukkien
1ea851bb53
Merge commit 'feb8e6c37947b21baaa7dcf724ade0f2435a8280'
github PR #152, also for issue #149
2024-04-13 13:36:11 +02:00
Mechiel Lukkien
34572d14d0
regenerate apidiff/next.txt after change to smtpclient
by calling "make genapidiff"
2024-04-13 13:31:32 +02:00
Mechiel Lukkien
73381d26ed
Merge commit 'be570d1c7d3de0ddacb011b6411a302d7f7e9f9e'
from github PR #153
2024-04-13 13:31:02 +02:00
Laurent Meunier
feb8e6c379 queue: retry with another IP when first attempt fails for dualstack remote servers
mox was already giving another try for received errors after the
`HELO`/`EHLO` command. Now mox do the same for received errors when
trying to deliver the message to the remote SMTP server.

This should help to deliver messages to SMTP server that rejects
incoming messages because of bad ipv4 or ipv6 configuration (for example
for servers checking reverse DNS records). mox will now try to deliver
messages on both ip family instead before considering the error as
permanent.

fix #149
2024-04-12 17:44:33 +02:00
Laurent Meunier
be570d1c7d add TransportDirect transport
The `TransportDirect` transport allows to tweak outgoing SMTP
connections to remote servers. Currently, it only allows to select
network IP family (ipv4, ipv6 or both).

For example, to disable ipv6 for all outgoing SMTP connections:
- add these lines in mox.conf to create a new transport named
"disableipv6":
```
Transports:
  disableipv6:
    Direct:
      DisableIpv6: true
```
- then add these lines in domains.conf to use this transport:
```
Routes:
  -
    Transport: disableipv6
```

fix #149
2024-04-12 17:27:39 +02:00
Mechiel Lukkien
f4b6e14cb9
quickstart: if initial address has non-ascii localpart, use "postmaster@" for registering with let's encrypt
because let's encrypt won't create an account for contact addresses with non-ascii characters.
we'll get an error message like:

	400 urn:ietf:params:acme:error:invalidContact: Error creating new account :: contact email [\"mailto:...\"] contains non-ASCII characters

found & reported by arnt, thanks!
2024-04-11 23:58:40 +02:00
Mechiel Lukkien
ad8c5616b1
do not use input type=email for email addresses
despite the name, it doesn't actually check for valid email addresses:
it doesn't allow non-ascii localparts, accepts various invalid localparts, and
rejects various valid localparts. no point in using it.
2024-04-11 23:45:47 +02:00
Mechiel Lukkien
606b915447
sync genapidiff 2024-04-11 23:28:52 +02:00
Mechiel Lukkien
00c8dacc56
fix previous commit, go fmt 2024-04-11 23:22:03 +02:00
Mechiel Lukkien
666f84edea
fix login for account names with non-ascii chars
we include the username in session cookie values. but cookie values must be ascii-only, go's net/http's drops bad values. the typical solution is to querystring-encode/decode the cookie values, which we'll now do.

problem found by arnt, thanks for reporting!
2024-04-11 23:11:31 +02:00
Mechiel Lukkien
d74610c345
bugfix: missing account close in queue direct send
found while writing new tests for upcoming functionality.
the test had an embarrassing workaround for the symptoms...
2024-04-08 20:22:52 +02:00
Mechiel Lukkien
89a9a8bc97
when we get a tls connection with an unrecognized sni hostname/ip, cause an alert "unrecognized name" rather than "internal error"
more helpful error for users trying to debug whats going on.

problem pointed out by arnt, thanks!
2024-04-08 14:22:52 +02:00
Mechiel Lukkien
ecf6163409
improve previous about using mtime from imported maildir message files
don't treat just any number from filename as timestamp. require it has 2 dots.
prevents filenames with just a number as being seen as a timestamp, like when
you import files from a mox accounts msgs directory.
2024-04-02 20:04:09 +02:00
Mechiel Lukkien
6d38a1e9a4
when reading maildirs for imports, use the file mtime as fallback for "received" time
more useful than the time.Time zero file in case the maildir filename isn't
properly formed with a timestamp. this is not too uncommon when people
reconstruct maildirs from other sources of message files to then import the
maildir.

based on message from abdul h
2024-04-02 19:43:45 +02:00
Mechiel Lukkien
96e3e5e33e
make staticcheck happy
i don't think it's actually better, but it is helpful to keep the code base
free of staticcheck findings.
2024-03-31 15:30:24 +02:00
Laurent Meunier
9c5d234162
do not require the SMTPUTF8 extension when not needed (#145)
Squashed commit of the following:

commit 11c25d727f0fff72bfb2dde5b0121d65be5cdc09
Author: Laurent Meunier <laurent@deltalima.net>
Date:   Sun Mar 31 12:37:09 2024 +0200

    Fix style issue

commit c075a8cd8bb116dc1b8ecae9880a70656d362714
Author: Laurent Meunier <laurent@deltalima.net>
Date:   Sun Mar 31 12:35:04 2024 +0200

    Also check smtputf8 for submitted messages or when in pedantic mode

commit c02328f881c653c1e84448233f6b04a6bc30bc4f
Author: Laurent Meunier <laurent@deltalima.net>
Date:   Sun Mar 31 12:33:20 2024 +0200

    Calls to `newParser` should use `c.smtputf8`

commit a0bbd13afc17e5bd7eb845d2045b8bc156c19d25
Author: Laurent Meunier <laurent@deltalima.net>
Date:   Sun Mar 31 12:32:12 2024 +0200

    Improve SMTPUTF8 tests

commit 08735690f3682e96b7f91cae2a32eaba7dc8b1f9
Author: Laurent Meunier <laurent@deltalima.net>
Date:   Sat Mar 30 17:22:33 2024 +0100

    do earlier smtputf8-check

commit 3484651691cb3a78062e5c19d5ac7046a5dfba7b
Author: Laurent Meunier <laurent@deltalima.net>
Date:   Thu Mar 28 17:47:11 2024 +0100

    do not require the SMTPUTF8 extension when not needed

    fix #145
2024-03-31 15:23:53 +02:00
Mechiel Lukkien
d34dd8aae6
update to latest bstore, with a bugfix for queries with multiple orders that were partially handled by an index
causing returned order to be incorrect.
was triggered by new code i'm working on.
2024-03-30 09:39:18 +01:00
Mechiel Lukkien
54b24931c9
add faq entry about configuring mox to send through a smart host
suggested by arnt & friend, thanks for reporting!
2024-03-27 10:23:37 +01:00
Mechiel Lukkien
6516a27689
update to latest sconf, which now gives more helpful error messages about some invalid config lines, like one with only whitespace
from arnt & friend, thanks for reporting!
2024-03-27 10:08:15 +01:00
Mechiel Lukkien
0262f4621e
in quickstart, check outgoing smtp connectivity by dialing gmail.com mx host
if connection cannot be made, warn about it and point to configuring a
smarthost and the config options.

suggested by arnt & friend
2024-03-27 09:35:16 +01:00
Mechiel Lukkien
d4958732c8
add more of a "getting started with building" to develop.txt
based on #145 by lmeunier
2024-03-26 09:34:03 +01:00
Mechiel Lukkien
40ade995a5
improve queue management
- add option to put messages in the queue "on hold", preventing delivery
  attempts until taken off hold again.
- add "hold rules", to automatically mark some/all submitted messages as "on
  hold", e.g. from a specific account or to a specific domain.
- add operation to "fail" a message, causing a DSN to be delivered to the
  sender. previously we could only drop a message from the queue.
- update admin page & add new cli tools for these operations, with new
  filtering rules for selecting the messages to operate on. in the admin
  interface, add filtering and checkboxes to select a set of messages to operate
  on.
2024-03-18 08:50:42 +01:00
Mechiel Lukkien
79f1054b64
factor common typescript api call code pattern into a function 2024-03-17 08:41:33 +01:00
Mechiel Lukkien
25b2ea164f
on build page, mention that changes can be tested easily with mox localserve 2024-03-17 07:58:02 +01:00
Mechiel Lukkien
79fb72f3cd
don't show default domain on admin account page
it is a remnant from the time domains didn't have to be specific in
"Destination" addresses. we still use it for as default selection for adding a
new address to an account. but there's not much point in showing it so
prominently. that raises more questions than it is helpful.

for issue #142 by tabatinga0xffff
2024-03-17 07:39:00 +01:00
Mechiel Lukkien
cef83341e5
make it harder to forget to set smtputf8 on message.Composer
we should do better: first gather all headers, and only write it when we start
on the body, and then calculate smtputf8 ourselves.
2024-03-16 20:59:19 +01:00
Mechiel Lukkien
8b2c97808d
add account option to skip the first-time sender delay
useful for accounts that automatically process messages and want to process quickly
2024-03-16 20:24:07 +01:00
Mechiel Lukkien
281411c297
add styling for sticky table headers, for scrolling with long tables 2024-03-16 19:27:29 +01:00
Mechiel Lukkien
fdee24f3bd
in web interfaces, put crumbs path in document title, for more useful browser history 2024-03-16 19:13:44 +01:00
Mechiel Lukkien
dfe587fdeb
prevent the help output of the reparse subcommand from appearing as a title in the generated documentation 2024-03-14 20:31:31 +01:00
Mechiel Lukkien
2c9cb5b847
add parser of Authentication-Results, and fix bugs it found in our generated headers
we weren't always quoting the values, like dkim's header.b=abc/def. the "/"
requires that the value be quoted.
2024-03-13 17:35:53 +01:00
Mechiel Lukkien
b91480b5af
add /b/ to website that explains how to compile mox, or gives a link to gobuild
the location.hash is used as the version to link to. this can be a tag
(release, e.g. v0.0.1), branch (e.g. main), or commit hash.
2024-03-12 09:41:09 +01:00
Mechiel Lukkien
411cb8fc78
for apidiff, generate apidiff/next.txt and rotate it on release
instead of already giving it a version name before the release. the released
version could be different.
2024-03-11 15:27:25 +01:00
Mechiel Lukkien
bcf737cbec
fix the Status command on imapclient.Conn
it needs at least 1 attribute.
also make types for those attributes, so its harder to get them wrong.
nothing was using this function.
2024-03-11 15:22:41 +01:00
Mechiel Lukkien
4dea2de343
implement imap quota extension (rfc 9208)
we only have a "storage" limit. for total disk usage. we don't have a limit on
messages (count) or mailboxes (count). also not on total annotation size, but
we don't have support annotations at all at the moment.

we don't implement setquota. with rfc 9208 that's allowed. with the previous
quota rfc 2087 it wasn't.

the status command can now return "DELETED-STORAGE". which should be the disk
space that can be reclaimed by removing messages with the \Deleted flags.
however, it's not very likely clients set the \Deleted flag without expunging
the message immediately. we don't want to go through all messages to calculate
the sum of message sizes with the deleted flag. we also don't currently track
that in MailboxCount. so we just respond with "0". not compliant, but let's
wait until someone complains.

when returning quota information, it is not possible to give the current usage
when no limit is configured. clients implementing rfc 9208 should probably
conclude from the presence of QUOTA=RES-* capabilities (only in rfc 9208, not
in 2087) and the absence of those limits in quota responses (or the absence of
an untagged quota response at all) that a resource type doesn't have a limit.
thunderbird will claim there is no quota information when no limit was
configured, so we can probably conclude that it implements rfc 2087, but not
rfc 9208.

we now also show the usage & limit on the account page.

for issue #115 by pmarini
2024-03-11 14:24:32 +01:00
Mechiel Lukkien
6c92949f13
in code/rfc cross-referenced side-by-side view, link to datatracker for rfc's 2024-03-11 09:14:26 +01:00
Mechiel Lukkien
4699504c9f
show goversion and goos/goarch on admin page 2024-03-11 08:58:40 +01:00
Mechiel Lukkien
b115c7b10d
detect whitespace issues in rfc/index.txt earlier
by checking with each fetch and update.
2024-03-11 08:46:40 +01:00
Mechiel Lukkien
5f1157060e
make video work on macos safari
by mentioning mp4 first.  it seems safari doesn't understand this webm
(resolution too high?). still doesn't seem to work on iphone/ipad safari.
2024-03-10 08:47:30 +01:00
Mechiel Lukkien
6984a2ae07
fix latest release on website, tweaks to release process 2024-03-09 20:45:23 +01:00
Mechiel Lukkien
f3501b4e06
fix spacing in rfc/index.txt
genwebsite fails on it.
will make tools that run more often on that file check more strictly too.
2024-03-09 19:55:37 +01:00
Mechiel Lukkien
c6eea5e1cf
add v0.0.10 to the website 2024-03-09 19:49:16 +01:00
Mechiel Lukkien
a601814c3d
fix build after previous commit 2024-03-09 15:52:28 +01:00
Mechiel Lukkien
0c800f3d7e
update to latest sherpats fixing typo in error message, handle absent dmarc "policy override" reason 2024-03-09 15:43:49 +01:00
Mechiel Lukkien
a96493946b
sync latest adns 2024-03-09 15:32:37 +01:00
Mechiel Lukkien
71981ebf43
ensure "make build" on macos generates the same documentation output
it has been i while since i used the old macos machine...
2024-03-09 15:06:42 +01:00
Mechiel Lukkien
a5163493e7
add release process note about updating website 2024-03-09 12:04:15 +01:00
Mechiel Lukkien
7969cf002a
allow zero configured addresses for an account
preventing writing out a domains.conf that is invalid and can't be parsed
again. this happens when the last address was removed from an account. just a
click in the admin web interface.

accounts without email address cannot log in.

for issue #133 by ally9335
2024-03-09 11:51:02 +01:00
Mechiel Lukkien
92e0d2a682
webadmin: be more helpful when adding domains/accounts/addresses
by explaining (in the titles/hovers) what the concepts and requirements are, by
using selects/dropdowns or datalist suggestions where we have a known list, by
automatically suggesting a good account name, and putting the input fields in a
more sensible order.

based on issue #132 by ally9335
2024-03-09 11:11:52 +01:00
Mechiel Lukkien
63cef8e3a5
webmail: fix for ignoring error about sending to invalid address
before, an error about an invalid address was not used, causing a delivery
attempt to an empty address (empty localpart/domain). delivery to that address
would fail, but we should've prevented that message from being queued at all.

additionally, an error in adding the message to the queue was ignored too.
2024-03-09 09:51:24 +01:00
Mechiel Lukkien
c57aeac7f0
prevent unicode-confusion in password by applying PRECIS, and username/email address by applying unicode NFC normalization
an é (e with accent) can also be written as e+\u0301. the first form is NFC,
the second NFD. when logging in, we transform usernames (email addresses) to
NFC. so both forms will be accepted. if a client is using NFD, they can log
in too.

for passwords, we apply the PRECIS "opaquestring", which (despite the name)
transforms the value too: unicode spaces are replaced with ascii spaces. the
string is also normalized to NFC. PRECIS may reject confusing passwords when
you set a password.
2024-03-09 09:20:29 +01:00
Mechiel Lukkien
8e6fe7459b
normalize localparts with unicode nfc when parsing
both when parsing our configs, and for incoming on smtp or in messages.
so we properly compare things like é and e+accent as equal, and accept the
different encodings of that same address.
2024-03-08 21:08:40 +01:00
Mechiel Lukkien
4fbd7abb57
update to latest adns, synced with Go's net 2024-03-08 15:31:54 +01:00
Mechiel Lukkien
a00b0ba6cd
add note about testing localserve on various OSes before release 2024-03-08 15:31:34 +01:00
Mechiel Lukkien
372585de72
build before running test-upgrade 2024-03-08 09:28:39 +01:00
Mechiel Lukkien
03e220c749
update dependencies 2024-03-08 09:28:09 +01:00
Mechiel Lukkien
a9f11b8fa3
fix changing domains.conf through admin with new MonitorDNSBLs present
by not clearing the existing derived info, we would detect duplicate domains
and refuse the changed config.
2024-03-07 11:26:53 +01:00
Mechiel Lukkien
df105a028c
unbreak enforcing dane since previous commits
by using the correct variable.
should have automated tests for this.
found it by manual test through email-security-scans.org, useful service!
2024-03-07 11:19:08 +01:00
Mechiel Lukkien
484ffa67d1
fix new reference to smtp limits rfc 2024-03-07 10:56:58 +01:00
Mechiel Lukkien
85f72582c6
mention matrix channel, add moxtools to things to check for a release 2024-03-07 10:51:48 +01:00
Mechiel Lukkien
b541646275
be more helpful about instructions for installing unbound and dnssec
by mentioning the dnssec root keys, mentioning which unbound version has EDE,
giving a "dig" invocation to check for dnssec results.

based on issue #131 by romner-set, thanks for reporting
2024-03-07 10:47:48 +01:00
Mechiel Lukkien
4db1f5593c
better check for dnssec-verifying resolver
check the authentic data bit for the NS records of "com.", not for ".": some
dnssec-verifying resolvers return unauthentic data for ".".

for issue #139 by triatic, thanks!
2024-03-07 10:34:13 +01:00
Mechiel Lukkien
9e7d6b85b7
queue: deliver to multiple recipients in a single smtp transaction
transferring the data only once. we only do this when the recipient domains
are the same. when queuing, we now take care to set the same NextAttempt
timestamp, so queued messages are actually eligable for combined delivery.

this adds a DeliverMultiple to the smtp client. for pipelined requests, it will
send all RCPT TO (and MAIL and DATA) in one go, and handles the various
responses and error conditions, returning either an overal error, or per
recipient smtp responses. the results of the smtp LIMITS extension are also
available in the smtp client now.

this also takes the "LIMITS RCPTMAX" smtp extension into account: if the server
only accepts a single recipient, we won't send multiple.
if a server doesn't announce a RCPTMAX limit, but still has one (like mox does
for non-spf-verified transactions), we'll recognize code 452 and 552 (for
historic reasons) as temporary error, and try again in a separate transaction
immediately after. we don't yet implement "LIMITS MAILMAX", doesn't seem likely
in practice.
2024-03-07 10:07:53 +01:00
Mechiel Lukkien
8550a5af45
don't expose functions on the prng that aren't mutex-protected
the current Intn calls in queue could be called concurrently, found by the race
detector with upcoming new tests.  best to just prevent any possible concurrent
access.
2024-03-07 10:05:35 +01:00
Mechiel Lukkien
47ebfa8152
queue: implement adding a message to the queue that gets sent to multiple recipients
and in a way that allows us to send that message to multiple recipients in a
single smtp transaction.
2024-03-05 20:10:28 +01:00
Mechiel Lukkien
15e450df61
implement only monitoring dns blocklists, without using them for incoming deliveries
so you can still know when someone has put you on their blocklist (which may
affect delivery), without using them.

also query dnsbls for our ips more often when we do more outgoing connections
for delivery: once every 100 messages, but at least 5 mins and at most 3 hours
since the previous check.
2024-03-05 19:37:48 +01:00
Mechiel Lukkien
e0c36edb8f
accept tls reports with both host & recipient domains, and with multiple recipient domains
embarrassingly, we didn't accept all reports we generated. after the changed
handling of reports about mx/mail host vs recipient domains, would send reports
to mail hosts about multiple recipient domains + the mail host. and we included
a policy domain of the mail host when sending to a recipient domain. we were
still being strict in what we accepted: only a single domain in total in the
entire report, and we still enforced that a report sent to the mx host tlsrpt
address only contained the mx host as policy domain. and likewise for recipient
domains and their tls reporting addresses. those checks would reject reports
generated by a mox instance. this probably only happens with dane configured,
probably most users haven't seen it because of that.

somewhat related to issue #125
2024-03-05 11:43:49 +01:00
Mechiel Lukkien
a9cb6f9d0a
webadmin: add single-line form for looking up a cid for a received id 2024-03-05 10:50:56 +01:00
Mechiel Lukkien
5738d9e7b8
when auth fails due to missing derived secrets, don't hold it against connection
smtp & imap can only indicate which mechanisms the server software supports.
individual accounts may not have derived secrets for all those mechanisms. imap
& smtp cannot indicate that a client should try another (specific) mechanism.
but at least we shouldn't slow the connection down due to failed auth attempts
in that case.

heard from ben that this is a common source for trouble when setting up email
accounts.
2024-03-05 10:40:40 +01:00
Mechiel Lukkien
caa4931d35
tweak faq about email being rejected 2024-03-05 09:41:44 +01:00
Mechiel Lukkien
af968f7614
webmail: for junk/rejects messages, show sender address instead of name in list 2024-03-05 09:04:59 +01:00
Mechiel Lukkien
79f91ebd87
webmail: don't switch back focus after autocompleting address
actually, this fix can reduce focus changes for more operations. withStatus is
often used to show an operation in progress in the status bar, only when the
operation isn't done within 1 second. we would restore focus to the element
before the operation started. that was done because we disable elements
sometimes (preventing duplicate form submission). for things like the
autocomplete, with the tab key, which also moves focus to the next element, we
don't want that focus switched back again.
2024-03-05 08:46:56 +01:00
Mechiel Lukkien
63c3c1fd6a
webmail: leave out own address in reply all when we have addresses remaining 2024-03-04 20:21:41 +01:00
Mechiel Lukkien
26ff0c9417
increase memory limit during tests for upgrade 2024-03-04 19:11:53 +01:00
Mechiel Lukkien
13923e4b7b
better thread matching for dsns
keep track of whether a message is a dsn, and match dsn's against their sent
message by ignoring the message subject.
2024-03-04 16:40:27 +01:00
Mechiel Lukkien
f6497b1aaf
when parsing a dsn, actually set the Action field
noticed when writing dsn-processing code
2024-02-21 21:19:52 +01:00
Mechiel Lukkien
79da4faaa1
add Delivered-To header when locally delivering a DSN
so tools can pick it up and find the original "MAIL FROM", and take the encode
destination address or message id from its localpart.
2024-02-20 16:39:49 +01:00
Mechiel Lukkien
1c934f0103
improve dsn handling
have the full smtp reply in the Diagnostic-Code field, not something that
resembles it but isn't quite the same.

include any additional error message in the Status field as comment.

before, we ended up having an Diagnostic-Code that didn't include the original
smtp code. it only had the enhanced error code.
2024-02-20 16:31:15 +01:00
Mechiel Lukkien
dc83ad1df5
set correct local account when adding a message to the queue
all dsns were going to the postmaster account...
2024-02-20 15:02:47 +01:00
Mechiel Lukkien
cb5097714b
add a few more rfc 2024-02-20 14:58:16 +01:00
Mechiel Lukkien
37de8de1c5
fix incorrect error about bare cr/lf when sending a message over smtp
we weren't properly tracking the cr's and lf's when being strict about message
lines when sending data.

we are reading buffered data from a Reader. if that chunk happens to start with
a newline, we weren't looking at the previously written data, which could be a
cr. instead, in that case, we would always claim the cr/lf wasn't correct.

the new test case triggered the behaviour before having the fix.

should solve issue #129 by x8x, thanks for the report!
2024-02-16 20:20:58 +01:00
Mechiel Lukkien
fd359d5973
add to previous commit, adding multiline smtp responses in dsn
also include api change.
2024-02-16 20:13:05 +01:00
Mechiel Lukkien
50c13965a7
include full smtp response in dsn on errors
we now keep track of the full smtp error responses, potentially multi-line. and
we include it in a dsn in the first free-form human-readable text.

it can have multiple lines in practice, e.g. when a destination mail server
tries to be helpful in explaining what the problem is.
2024-02-14 23:37:43 +01:00
Mechiel Lukkien
39bfa4338a
smtpclient: only obey SIZE= of server if it isn't 0
since that means there is no explicit limit.
2024-02-14 17:46:01 +01:00
Mechiel Lukkien
8046b323fb
fix and ensure consistent lines 2024-02-14 17:43:21 +01:00
Mechiel Lukkien
67300969c1
don't use bash if not needed
from mteege
2024-02-11 21:46:45 +01:00
Mechiel Lukkien
93c52b01a0
implement "future release"
the smtp extension, rfc 4865.
also implement in the webmail.
the queueing/delivery part hardly required changes: we just set the first
delivery time in the future instead of immediately.

still have to find the first client that implements it.
2024-02-10 17:55:56 +01:00
Mechiel Lukkien
17734196e3
add rfc 9078, "Reaction: Indicating Summary Reaction to a Message" to the list
about emoji responses to messages.

no concrete plans (lack of time), but would be fun to experiment with in the
webmail.
2024-02-10 12:14:36 +01:00
Mechiel Lukkien
49c8dbf47e
add FAQ about directly accessing mailboxes through the file system
commonly asked, again at fosdem.
2024-02-10 11:39:31 +01:00
Mechiel Lukkien
ee1db2dde7
webmail: implement registering and handling "mailto:" links
to start composing a message.

the help popup now has a button to register the "mailto:" links with the mox
webmail (typically only works over https, not all browsers support it).

the mailto links are specified in 6068. we support the to/cc/bcc/subject/body
parameters. other parameters should be seen as custom headers, but we don't
support messages with custom headers at all at the moment, so we ignore them.

we now also turn text of the form "mailto:user@host" into a clickable link
(will not be too common). we could be recognizing any "x@x.x" as email address
and make them clickable in the future.

thanks to Hans-Jörg for explaining this functionality.
2024-02-09 11:21:33 +01:00
Mechiel Lukkien
f3bf348214
webmail: show unicode for internationalized email addresses by default
before, we showed the xn-- ascii names, along with the unicode name. but users
of internationalized email don't want to see any xn-- names. we now put those
in an html title attribute for some cases, so you can still see them if you
really want to, by hovering.

after talking to arnt at fosdem.
2024-02-08 18:03:48 +01:00
Mechiel Lukkien
39f4800290
xr: unbreak following links, they were now being opened in a new window
broken in previous update. the tricky part keeps being about when browsers fire
'load' and 'hashchange' events for the outer and two inner documents. the
previous change attempted to prevent a history item being set on the first
load. that behaviour seems to be kept.
2024-02-08 16:25:33 +01:00
Mechiel Lukkien
4ea9e9e978
run more of go vet on the special-purpose tools
tools that are behind build constraints
2024-02-08 15:12:43 +01:00
Mechiel Lukkien
61836f6d00
don't shadow variables, no empty "else" blocks
from go vet and staticcheck
2024-02-08 15:12:06 +01:00
Mechiel Lukkien
5f40d23c1c
remove unused build constraint 2024-02-08 15:10:32 +01:00
Mechiel Lukkien
e75419aeaf
unbreak rfc/xr.go after changing golang.org/x/exp/maps
shouldn't have changed this one.
2024-02-08 15:08:26 +01:00
Mechiel Lukkien
d1b87cdb0d
replace packages slog and slices from golang.org/x/exp with stdlib
since we are now at go1.21 as minimum.
2024-02-08 14:49:01 +01:00
Mechiel Lukkien
c698cd07d9
apidiff: properly check against actual previous version
not hardcoded v0.0.8...
2024-02-08 14:46:31 +01:00
Mechiel Lukkien
ecf60568b4
fix: don't insert spurious \r when fixing up crlf line endings when writing a message
message.Writer.Write() adds missing \r's, but the buffer of "last bytes
written" was only being updated while writing the message headers, not while
writing the body. so for Write()'s in the body section (depending on
buffering), we were compensating based on the "last bytes written" as set
during the last write in the header section. that could cause a spurious \r to
be added when a Write starts with \n while the previous Write did properly
end with \r.

for issue #117, thanks haraldrudell for reporting and investigating
2024-02-08 12:33:19 +01:00
Mechiel Lukkien
dd540e401a
replace another "/bin/bash" with "/usr/bin/env bash" and remove old file 2024-02-01 09:03:32 +01:00
Pierre-Alain TORET
5f297ce54c Improve portability of build scripts 2024-02-01 09:00:21 +01:00
Mechiel Lukkien
1d9e80fd70
for domains configured only for reporting, don't reject messages to that domain during smtp submission
you can configure a domain only to accept dmarc/tls reports. those domains
won't have addresses for that domain configured (the reporting destination
address is for another domain). we already handled such domains specially in a
few places. but we were considering ourselves authoritative for such domains if
an smtp client would send a message to the domain during submit. and we would
reject all recipient addresses. but we should be trying to deliver those
messages to the actual mx hosts for the domain, which we will now do.
2024-01-26 19:51:23 +01:00
Mechiel Lukkien
a524c3a50b
clarify unicode domain names in config file 2024-01-24 10:48:44 +01:00
Mechiel Lukkien
62be829df0
when sending tls reports, ensure we use ASCII A-labels, not U-labels in the policy-domain field 2024-01-24 10:36:20 +01:00
Mechiel Lukkien
14aa85482e
imapserver: fix interpreting the first "*" in sequence/uid patterns, like "*:123" or plain "*"
in some cases, they were interpreted as meaning "the first sequence/uid", but
it should always be "the last sequence/uid", just like patterns of the form
"123:*".

this wrong interpretation was used in the "fetch" command when combined with
"changedsince", and in the search command for some parameters, and during
expunge with an explicit uid range. the form "*" and "*:123" aren't very
common.
2024-01-23 21:21:08 +01:00
Mechiel Lukkien
d9dde0d89e
tweaks to cross-referenced html
- on the two index pages, show rows with alternating background color so the
  files in the 2nd column are more easily matched to the name in the 1st
  column.
- unbreak browser history when navigating files/line numbers. changing an
  iframe src attribute adds an entry to the history. that happens on "back" to,
  causing a 2nd "back" to go forward again. instead of replacing the iframe src,
  we now replace the iframe, as that doesn't cause an entry to be added to the
  browser history. dark browser magic...
2024-01-23 19:29:20 +01:00
Mechiel Lukkien
9cf8ee2162
webmail: don't who an age of "-<1min", drop the -
if a browser is ahead just a few seconds, we would show "-<1min", not great.
just show "<1min" in that case. we'll still show negative age if drift is more
than 1 minute, which seems like a good hint to get time fixed on either client
or server.
2024-01-23 17:01:34 +01:00
Mechiel Lukkien
ed8938c113
fix typo in config field explanation 2024-01-23 16:59:08 +01:00
Mechiel Lukkien
20812dcf62
add types for missing dmarc report values in reports
so admin frontend doesn't complain about invalid values (empty strings).
2024-01-23 16:51:05 +01:00
Mechiel Lukkien
46aacdb79b
webmail: when q/b-word-decoding attachment filenames, recognize more charset encodings
based on #113 by jsfan3
2024-01-12 15:25:23 +01:00
Mechiel Lukkien
aea8740e65
quota: fix handling negative max size when configured for an account, and clarify value is in bytes in config file
for #115 by pmarini-nc
2024-01-12 15:02:16 +01:00
Mechiel Lukkien
7b6cfcd572
add quickstart video 2024-01-11 23:01:04 +01:00
Mechiel Lukkien
0bc3072944
new website for www.xmox.nl
most content is in markdown files in website/, some is taken out of the repo
README and rfc/index.txt. a Go file generates html. static files are kept in a
separate repo due to size.
2024-01-10 17:22:03 +01:00
873 changed files with 205484 additions and 33994 deletions

View File

@ -17,7 +17,6 @@ jobs:
with:
node-version: 16
cache: 'npm'
- run: npm ci
- run: 'touch */*.ts'
- uses: actions/setup-go@v4
@ -28,8 +27,9 @@ jobs:
# Need to run tests with a temp dir on same file system for os.Rename to succeed.
- run: 'mkdir -p tmp && TMPDIR=$PWD/tmp make test'
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.go-version }}
path: cover.html
# Format code, we check below if nothing changed.

4
.gitignore vendored
View File

@ -1,10 +1,11 @@
/mox
/mox.exe
/rfc/[0-9][0-9]*
/rfc/xr/
/local/
/testdata/check/
/testdata/*/data/
/testdata/ctl/dkim/
/testdata/ctl/config/dkim/
/testdata/empty/
/testdata/exportmaildir/
/testdata/exportmbox/
@ -24,3 +25,4 @@
/node_modules/
/upgrade*-verifydata.*.pprof
/upgrade*-openaccounts.*.pprof
/website/html/

151
Makefile
View File

@ -6,11 +6,8 @@ build0:
# build early to catch syntax errors
CGO_ENABLED=0 go build
CGO_ENABLED=0 go vet ./...
CGO_ENABLED=0 go vet -tags integration
./gendoc.sh
(cd webadmin && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none Admin) >webadmin/api.json
(cd webaccount && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none Account) >webaccount/api.json
(cd webmail && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none Webmail) >webmail/api.json
./genapidoc.sh
./gents.sh webadmin/api.json webadmin/api.ts
./gents.sh webaccount/api.json webaccount/api.ts
./gents.sh webmail/api.json webmail/api.ts
@ -19,97 +16,144 @@ build1:
# build again, api json files above are embedded and new frontend code generated
CGO_ENABLED=0 go build
install: build0 frontend
CGO_ENABLED=0 go install
race: build0
go build -race
test:
CGO_ENABLED=0 go test -shuffle=on -coverprofile cover.out ./...
CGO_ENABLED=0 go test -fullpath -shuffle=on -coverprofile cover.out ./...
go tool cover -html=cover.out -o cover.html
test-race:
CGO_ENABLED=1 go test -race -shuffle=on -covermode atomic -coverprofile cover.out ./...
CGO_ENABLED=1 go test -fullpath -race -shuffle=on -covermode atomic -coverprofile cover.out ./...
go tool cover -html=cover.out -o cover.html
test-more:
TZ= CGO_ENABLED=0 go test -fullpath -shuffle=on -count 2 ./...
# note: if testdata/upgradetest.mbox.gz exists, its messages will be imported
# during tests. helpful for performance/resource consumption tests.
test-upgrade:
test-upgrade: build
nice ./test-upgrade.sh
# needed for "check" target
install-staticcheck:
CGO_ENABLED=0 go install honnef.co/go/tools/cmd/staticcheck@latest
install-ineffassign:
CGO_ENABLED=0 go install github.com/gordonklaus/ineffassign@v0.1.0
check:
staticcheck ./...
staticcheck -tags integration
CGO_ENABLED=0 go vet -tags integration
CGO_ENABLED=0 go vet -tags website website/website.go
CGO_ENABLED=0 go vet -tags link rfc/link.go
CGO_ENABLED=0 go vet -tags errata rfc/errata.go
CGO_ENABLED=0 go vet -tags xr rfc/xr.go
GOARCH=386 CGO_ENABLED=0 go vet ./...
CGO_ENABLED=0 ineffassign ./...
CGO_ENABLED=0 staticcheck ./...
CGO_ENABLED=0 staticcheck -tags integration
CGO_ENABLED=0 staticcheck -tags website website/website.go
CGO_ENABLED=0 staticcheck -tags link rfc/link.go
CGO_ENABLED=0 staticcheck -tags errata rfc/errata.go
CGO_ENABLED=0 staticcheck -tags xr rfc/xr.go
# needed for check-shadow
install-shadow:
CGO_ENABLED=0 go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
# having "err" shadowed is common, best to not have others
check-shadow:
go vet -vettool=$$(which shadow) ./... 2>&1 | grep -v '"err"'
CGO_ENABLED=0 go vet -vettool=$$(which shadow) ./... 2>&1 | grep -v '"err"'
CGO_ENABLED=0 go vet -tags integration -vettool=$$(which shadow) 2>&1 | grep -v '"err"'
CGO_ENABLED=0 go vet -tags website -vettool=$$(which shadow) website/website.go 2>&1 | grep -v '"err"'
CGO_ENABLED=0 go vet -tags link -vettool=$$(which shadow) rfc/link.go 2>&1 | grep -v '"err"'
CGO_ENABLED=0 go vet -tags errata -vettool=$$(which shadow) rfc/errata.go 2>&1 | grep -v '"err"'
CGO_ENABLED=0 go vet -tags xr -vettool=$$(which shadow) rfc/xr.go 2>&1 | grep -v '"err"'
fuzz:
go test -fuzz FuzzParseSignature -fuzztime 5m ./dkim
go test -fuzz FuzzParseRecord -fuzztime 5m ./dkim
go test -fuzz . -fuzztime 5m ./dmarc
go test -fuzz . -fuzztime 5m ./dmarcrpt
go test -fuzz . -parallel 1 -fuzztime 5m ./imapserver
go test -fuzz . -parallel 1 -fuzztime 5m ./junk
go test -fuzz FuzzParseRecord -fuzztime 5m ./mtasts
go test -fuzz FuzzParsePolicy -fuzztime 5m ./mtasts
go test -fuzz . -parallel 1 -fuzztime 5m ./smtpserver
go test -fuzz . -fuzztime 5m ./spf
go test -fuzz FuzzParseRecord -fuzztime 5m ./tlsrpt
go test -fuzz FuzzParseMessage -fuzztime 5m ./tlsrpt
go test -fullpath -fuzz FuzzParseSignature -fuzztime 5m ./dkim
go test -fullpath -fuzz FuzzParseRecord -fuzztime 5m ./dkim
go test -fullpath -fuzz . -fuzztime 5m ./dmarc
go test -fullpath -fuzz . -fuzztime 5m ./dmarcrpt
go test -fullpath -fuzz . -parallel 1 -fuzztime 5m ./imapserver
go test -fullpath -fuzz . -fuzztime 5m ./imapclient
go test -fullpath -fuzz . -parallel 1 -fuzztime 5m ./junk
go test -fullpath -fuzz FuzzParseRecord -fuzztime 5m ./mtasts
go test -fullpath -fuzz FuzzParsePolicy -fuzztime 5m ./mtasts
go test -fullpath -fuzz . -fuzztime 5m ./smtp
go test -fullpath -fuzz . -parallel 1 -fuzztime 5m ./smtpserver
go test -fullpath -fuzz . -fuzztime 5m ./spf
go test -fullpath -fuzz FuzzParseRecord -fuzztime 5m ./tlsrpt
go test -fullpath -fuzz FuzzParseMessage -fuzztime 5m ./tlsrpt
govendor:
go mod tidy
go mod vendor
./genlicenses.sh
test-integration:
-docker compose -f docker-compose-integration.yml kill
-docker compose -f docker-compose-integration.yml down
docker image build --pull --no-cache -f Dockerfile -t mox_integration_moxmail .
docker image build --pull --no-cache -f testdata/integration/Dockerfile.test -t mox_integration_test testdata/integration
-rm -rf testdata/integration/moxacmepebble/data
-rm -rf testdata/integration/moxmail2/data
-rm -f testdata/integration/tmp-pebble-ca.pem
MOX_UID=$$(id -u) docker-compose -f docker-compose-integration.yml run test
docker-compose -f docker-compose-integration.yml down --timeout 1
MOX_UID=$$(id -u) docker compose -f docker-compose-integration.yml run test
docker compose -f docker-compose-integration.yml kill
imaptest-build:
-docker-compose -f docker-compose-imaptest.yml build --no-cache --pull mox
-docker compose -f docker-compose-imaptest.yml build --no-cache --pull mox
imaptest-run:
-rm -r testdata/imaptest/data
mkdir testdata/imaptest/data
docker-compose -f docker-compose-imaptest.yml run --entrypoint /usr/local/bin/imaptest imaptest host=mox port=1143 user=mjl@mox.example pass=testtest mbox=imaptest.mbox
docker-compose -f docker-compose-imaptest.yml down
docker compose -f docker-compose-imaptest.yml run --entrypoint /usr/local/bin/imaptest imaptest host=mox port=1143 user=mjl@mox.example pass=testtest mbox=imaptest.mbox
docker compose -f docker-compose-imaptest.yml down
fmt:
go fmt ./...
gofmt -w -s *.go */*.go
jswatch:
tswatch:
bash -c 'while true; do inotifywait -q -e close_write *.ts webadmin/*.ts webaccount/*.ts webmail/*.ts; make frontend; done'
jsinstall:
node_modules/.bin/tsc:
-mkdir -p node_modules/.bin
npm ci
npm ci --ignore-scripts
jsinstall0:
install-js: node_modules/.bin/tsc
install-js0:
-mkdir -p node_modules/.bin
npm install --save-dev --save-exact typescript@5.1.6
npm install --ignore-scripts --save-dev --save-exact typescript@5.1.6
webmail/webmail.js: lib.ts webmail/api.ts webmail/lib.ts webmail/webmail.ts
./tsc.sh $@ $^
./tsc.sh $@ lib.ts webmail/api.ts webmail/lib.ts webmail/webmail.ts
webmail/msg.js: lib.ts webmail/api.ts webmail/lib.ts webmail/msg.ts
./tsc.sh $@ $^
./tsc.sh $@ lib.ts webmail/api.ts webmail/lib.ts webmail/msg.ts
webmail/text.js: lib.ts webmail/api.ts webmail/lib.ts webmail/text.ts
./tsc.sh $@ $^
./tsc.sh $@ lib.ts webmail/api.ts webmail/lib.ts webmail/text.ts
webadmin/admin.js: lib.ts webadmin/api.ts webadmin/admin.ts
./tsc.sh $@ $^
./tsc.sh $@ lib.ts webadmin/api.ts webadmin/admin.ts
webaccount/account.js: lib.ts webaccount/api.ts webaccount/account.ts
./tsc.sh $@ $^
./tsc.sh $@ lib.ts webaccount/api.ts webaccount/account.ts
frontend: webadmin/admin.js webaccount/account.js webmail/webmail.js webmail/msg.js webmail/text.js
frontend: node_modules/.bin/tsc webadmin/admin.js webaccount/account.js webmail/webmail.js webmail/msg.js webmail/text.js
install-apidiff:
CGO_ENABLED=0 go install golang.org/x/exp/cmd/apidiff@v0.0.0-20231206192017-f3f8817b8deb
genapidiff:
# needs file next.txt containing next version number, and golang.org/x/exp/cmd/apidiff@v0.0.0-20231206192017-f3f8817b8deb installed
./apidiff.sh
docker:
@ -118,18 +162,21 @@ docker:
docker-release:
./docker-release.sh
genwebsite:
./genwebsite.sh
buildall:
GOOS=linux GOARCH=arm go build
GOOS=linux GOARCH=arm64 go build
GOOS=linux GOARCH=amd64 go build
GOOS=linux GOARCH=386 go build
GOOS=openbsd GOARCH=amd64 go build
GOOS=freebsd GOARCH=amd64 go build
GOOS=netbsd GOARCH=amd64 go build
GOOS=darwin GOARCH=amd64 go build
GOOS=dragonfly GOARCH=amd64 go build
GOOS=illumos GOARCH=amd64 go build
GOOS=solaris GOARCH=amd64 go build
GOOS=aix GOARCH=ppc64 go build
GOOS=windows GOARCH=amd64 go build
CGO_ENABLED=0 GOOS=linux GOARCH=arm go build
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build
CGO_ENABLED=0 GOOS=openbsd GOARCH=amd64 go build
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build
CGO_ENABLED=0 GOOS=netbsd GOARCH=amd64 go build
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build
CGO_ENABLED=0 GOOS=dragonfly GOARCH=amd64 go build
CGO_ENABLED=0 GOOS=illumos GOARCH=amd64 go build
CGO_ENABLED=0 GOOS=solaris GOARCH=amd64 go build
CGO_ENABLED=0 GOOS=aix GOARCH=ppc64 go build
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build
# no plan9 for now

330
README.md
View File

@ -1,5 +1,7 @@
Mox is a modern full-featured open source secure mail server for low-maintenance self-hosted email.
For more details, see the mox website, https://www.xmox.nl.
See Quickstart below to get started.
## Features
@ -17,20 +19,22 @@ See Quickstart below to get started.
(similar to greylisting). Rejected emails are stored in a mailbox called Rejects
for a short period, helping with misclassified legitimate synchronous
signup/login/transactional emails.
- Internationalized email, with unicode in email address usernames
- Internationalized email (EIA), with unicode in email address usernames
("localparts"), and in domain names (IDNA).
- Automatic TLS with ACME, for use with Let's Encrypt and other CA's.
- DANE and MTA-STS for inbound and outbound delivery over SMTP with STARTTLS,
including REQUIRETLS and with incoming/outgoing TLSRPT reporting.
- Web admin interface that helps you set up your domains and accounts
(instructions to create DNS records, configure
SPF/DKIM/DMARC/TLSRPT/MTA-STS), for status information, managing
accounts/domains, and modifying the configuration file.
- Web admin interface that helps you set up your domains, accounts and list
aliases (instructions to create DNS records, configure
SPF/DKIM/DMARC/TLSRPT/MTA-STS), for status information, and modifying the
configuration file.
- Account autodiscovery (with SRV records, Microsoft-style, Thunderbird-style,
and Apple device management profiles) for easy account setup (though client
support is limited).
- Webserver with serving static files and forwarding requests (reverse
proxy), so port 443 can also be used to serve websites.
- Simple HTTP/JSON API for sending transaction email and receiving delivery
events and incoming messages (webapi and webhooks).
- Prometheus metrics and structured logging for operational insight.
- "mox localserve" subcommand for running mox locally for email-related
testing/developing, including pedantic mode.
@ -48,12 +52,12 @@ proton.me.
The code is heavily cross-referenced with the RFCs for readability/maintainability.
# Quickstart
The easiest way to get started with serving email for your domain is to get a
(virtual) machine dedicated to serving email, name it [host].[domain] (e.g.
mail.example.com), login as root, and run:
(virtual) machine dedicated to serving email, name it `[host].[domain]` (e.g.
mail.example.com). Having a DNSSEC-verifying resolver installed, such as
unbound, is highly recommended. Run as root:
# Create mox user and homedir (or pick another name or homedir):
useradd -m -d /home/mox mox
@ -64,99 +68,122 @@ mail.example.com), login as root, and run:
# Generate config files for your address/domain:
./mox quickstart you@example.com
The quickstart creates configuration files for the domain and account,
generates an admin and account password, prints the DNS records you need to add
and prints commands to start mox and optionally install mox as a service.
The quickstart:
- Creates configuration files mox.conf and domains.conf.
- Adds the domain and an account for the email address to domains.conf
- Generates an admin and account password.
- Prints the DNS records you need to add, for the machine and domain.
- Prints commands to start mox, and optionally install mox as a service.
A machine that doesn't already run a webserver is highly recommended because
modern email requires HTTPS, and mox currently needs it for automatic TLS. You
could combine mox with an existing webserver, but it requires a lot more
configuration. If you want to serve websites on the same machine, consider using
the webserver built into mox. It's pretty good! If you want to run an existing
webserver on port 443/80, see "mox help quickstart".
modern email requires HTTPS, and mox currently needs to run a webserver for
automatic TLS with ACME. You could combine mox with an existing webserver, but
it requires a lot more configuration. If you want to serve websites on the same
machine, consider using the webserver built into mox. It's pretty good! If you
want to run an existing webserver on port 443/80, see `mox help quickstart`.
After starting, you can access the admin web interface on internal IPs.
# Download
You can easily (cross) compile mox if you have a recent Go toolchain installed
(see "go version", it must be >= 1.20; otherwise, see https://go.dev/dl/ or
https://go.dev/doc/manage-install and $HOME/go/bin):
Download a mox binary from
https://beta.gobuilds.org/github.com/mjl-/mox@latest/linux-amd64-latest/.
Symlink or rename it to "mox".
The URL above always resolves to the latest release for linux/amd64 built with
the latest Go toolchain. See the links at the bottom of that page for binaries
for other platforms.
# Compiling
You can easily (cross) compile mox yourself. You need a recent Go toolchain
installed. Run `go version`, it must be >= 1.23. Download the latest version
from https://go.dev/dl/ or see https://go.dev/doc/manage-install.
To download the source code of the latest release, and compile it to binary "mox":
GOBIN=$PWD CGO_ENABLED=0 go install github.com/mjl-/mox@latest
Or you can download a binary built with the latest Go toolchain from
https://beta.gobuilds.org/github.com/mjl-/mox@latest/linux-amd64-latest/, and
symlink or rename it to "mox".
Verify you have a working mox binary:
./mox version
Mox only compiles for and fully works on unix systems. Mox also compiles for
Windows, but "mox serve" does not yet work, though "mox localserve" (for a
local test instance) and most other subcommands do. Mox does not compile for
Plan 9.
You can also run mox with docker image `r.xmox.nl/mox`, with tags like `v0.0.1`
and `v0.0.1-go1.20.1-alpine3.17.2`, see https://r.xmox.nl/r/mox/. Though new
docker images aren't (automatically) generated for new Go runtime/compile
releases. See docker-compose.yml in this repository for instructions on
starting. It is important to run with docker host networking, so mox can use
the public IPs and has correct remote IP information for incoming connections
(important for junk filtering and rate-limiting). Given these caveats, it's
recommended to run mox without docker.
# Docker
# Future/development
Although not recommended, you can also run mox with docker image
`r.xmox.nl/mox`, with tags like `v0.0.1` and `v0.0.1-go1.20.1-alpine3.17.2`, see
https://r.xmox.nl/r/mox/. See
https://github.com/mjl-/mox/blob/main/docker-compose.yml to get started.
Mox will receive funding for essentially full-time continued work from August
2023 to August 2024 through NLnet/EU's NGI0 Entrust, see
https://nlnet.nl/project/Mox/.
New docker images aren't (automatically) generated for new Go runtime/compile
releases.
## Roadmap
It is important to run with docker host networking, so mox can use the public
IPs and has correct remote IP information for incoming connections (important
for junk filtering and rate-limiting).
- Improve documentation
- Improve SMTP delivery from queue
- Webmail improvements
- HTTP-based API for sending messages and receiving delivery feedback
# Development
See develop.txt for instructions/tips for developing on mox.
# Sponsors
Thanks to NLnet foundation, the European Commission's NGI programme, and the
Netherlands Ministry of the Interior and Kingdom Relations for financial
support:
- 2024/2025, NLnet NGI0 Zero Core, https://nlnet.nl/project/Mox-Automation/
- 2024, NLnet e-Commons Fund, https://nlnet.nl/project/Mox-API/
- 2023/2024, NLnet NGI0 Entrust, https://nlnet.nl/project/Mox/
# Roadmap
- "mox setup" command, using admin web interface for interactive setup
- Automate DNS management, for setup and maintenance, such as DANE/DKIM key rotation
- Config options for "transactional email domains", for which mox will only
send messages
- Encrypted storage of files (email messages, TLS keys), also with per account keys
- Recognize common deliverability issues and help postmasters solve them
- JMAP, IMAP OBJECTID extension, IMAP JMAPACCESS extension
- Calendaring with CalDAV/iCal
- More IMAP extensions (PREVIEW, WITHIN, IMPORTANT, COMPRESS=DEFLATE,
CREATE-SPECIAL-USE, SAVEDATE, UNAUTHENTICATE, REPLACE, QUOTA, NOTIFY,
MULTIAPPEND, OBJECTID, MULTISEARCH)
- ARC, with forwarded email from trusted source
- Forwarding (to an external address)
- Introbox, to which first-time senders are delivered
- Add special IMAP mailbox ("Queue?") that contains queued but
undelivered messages, updated with IMAP flags/keywords/tags and message headers.
- Sieve for filtering (for now see Rulesets in the account config)
- Expose threading through IMAP extension
- External addresses in aliases/lists.
- Autoresponder (out of office/vacation)
- OAUTH2 support, for single sign on
- Privilege separation, isolating parts of the application to more restricted
sandbox (e.g. new unauthenticated connections)
- Using mox as backup MX
- JMAP
- Milter support, for integration with external tools
- Mailing list manager
- IMAP extensions for "online"/non-syncing/webmail clients (SORT (including
DISPLAYFROM, DISPLAYTO), THREAD, PARTIAL, CONTEXT=SEARCH CONTEXT=SORT ESORT,
FILTERS)
- IMAP Sieve extension, to run Sieve scripts after message changes (not only
new deliveries)
- IMAP ACL support, for account sharing (interacts with many extensions and code)
- Improve support for mobile clients with extensions: IMAP URLAUTH, SMTP
CHUNKING and BINARYMIME, IMAP CATENATE
- Privilege separation, isolating parts of the application to more restricted
sandbox (e.g. new unauthenticated connections)
- Using mox as backup MX
- Sieve for filtering (for now see Rulesets in the account config)
- ARC, with forwarded email from trusted source
- Milter support, for integration with external tools
- SMTP DSN extension
- IMAP Sieve extension, to run Sieve scripts after message changes (not only
new deliveries)
- OAUTH2 support, for single sign on
- Forwarding (to an external address)
There are many smaller improvements to make as well, search for "todo" in the code.
## Not supported/planned
But perhaps in the future...
There is currently no plan to implement the following. Though this may
change in the future.
- Mailing list manager
- Functioning as SMTP relay
- Functioning as an SMTP relay without authentication
- POP3
- Delivery to (unix) OS system users
- Delivery to (unix) OS system users (mbox/Maildir)
- Support for pluggable delivery mechanisms
- iOS Mail push notifications (with XAPPLEPUSHSERVICE undocumented imap
extension and hard to get APNS certificate)
# FAQ - Frequently Asked Questions
@ -175,11 +202,15 @@ make that easy.
## Where is the documentation?
See all commands and help output at https://pkg.go.dev/github.com/mjl-/mox/.
To keep mox as a project maintainable, documentation is integrated into, and
generated from the code.
See the commented example config files at
https://pkg.go.dev/github.com/mjl-/mox/config/. They often contain enough
documentation about a feature and how to configure it.
A list of mox commands, and their help output, are at
https://www.xmox.nl/commands/.
Mox is configured through configuration files, and each field comes with
documentation. See https://www.xmox.nl/config/ for config files containing all
fields and their documentation.
You can get the same information by running "mox" without arguments to list its
subcommands and usage, and "mox help [subcommand]" for more details.
@ -187,9 +218,8 @@ subcommands and usage, and "mox help [subcommand]" for more details.
The example config files are printed by "mox config describe-static" and "mox
config describe-dynamic".
Mox is still in early stages, and documentation is still limited. Please create
an issue describing what is unclear or confusing, and we'll try to improve the
documentation.
If you're missing some documentation, please create an issue describing what is
unclear or confusing, and we'll try to improve the documentation.
## Is Mox affected by SMTP smuggling?
@ -265,23 +295,24 @@ MIT license (like mox), and have the rights to do so.
## Where can I discuss mox?
Join #mox on irc.oftc.net, or #mox on the "Gopher slack".
Join #mox on irc.oftc.net, or #mox:matrix.org (https://matrix.to/#/#mox:matrix.org),
or #mox on the "Gopher slack".
For bug reports, please file an issue at https://github.com/mjl-/mox/issues/new.
## How do I change my password?
Regular users (doing IMAP/SMTP with authentication) can change their password
at the account page, e.g. http://localhost/. Or you can set a password with "mox
at the account page, e.g. `http://localhost/`. Or you can set a password with "mox
setaccountpassword".
The admin can change the password of any account through the admin page, at
http://localhost/admin/ by default (leave username empty when logging in).
`http://localhost/admin/` by default (leave username empty when logging in).
The account and admin pages are served on localhost for configs created with
the quickstart. To access these from your browser, run
`ssh -L 8080:localhost:80 you@yourmachine` locally and open
http://localhost:8080/[...].
`http://localhost:8080/[...]`.
The admin password can be changed with "mox setadminpassword".
@ -323,15 +354,18 @@ in place and restart. If manual actions are required, the release notes mention
them. Check the release notes of all version between your current installation
and the release you're upgrading to.
Before upgrading, make a backup of the data directory with `mox backup
<destdir>`. This writes consistent snapshots of the database files, and
duplicates message files from the outgoing queue and accounts. Using the new
mox binary, run `mox verifydata <backupdir>` (do NOT use the "live" data
directory!) for a dry run. If this fails, an upgrade will probably fail too.
Important: verifydata with the new mox binary can modify the database files (due
to automatic schema upgrades). So make a fresh backup again before the actual
upgrade. See the help output of the "backup" and "verifydata" commands for more
details.
Before upgrading, make a backup of the config & data directory with `mox backup
<destdir>`. This copies all files from the config directory to
`<destdir>/config`, and creates `<destdir>/data` with a consistent snapshots of
the database files, and message files from the outgoing queue and accounts.
Using the new mox binary, run `mox verifydata <destdir>/data` (do NOT use the
"live" data directory!) for a dry run. If this fails, an upgrade will probably
fail too.
Important: verifydata with the new mox binary can modify the database files
(due to automatic schema upgrades). So make a fresh backup again before the
actual upgrade. See the help output of the "backup" and "verifydata" commands
for more details.
During backup, message files are hardlinked if possible, and copied otherwise.
Using a destination directory like `data/tmp/backup` increases the odds
@ -371,19 +405,6 @@ should account for the size of the email messages (no compression currently),
an additional 15% overhead for the meta data, and add some more headroom.
Expand as necessary.
## Can I see some screenshots?
Yes, see https://www.xmox.nl/screenshots/.
Mox has a webmail for reading/writing messages.
Mox also has an "account" web interface where users can view their account and
manage their address configuration, such as rules for automatically delivering
certain incoming messages to a specific mailbox.
And mox has an "admin" web interface where the administrator can make changes,
e.g. add/remove/modify domains/accounts/addresses.
## Won't the big email providers block my email?
It is a common misconception that it is impossible to run your own email server
@ -413,12 +434,50 @@ to determine if your email message could be spam and should be rejected. Mox
helps you set up a system that doesn't trigger most of the technical signals
(e.g. with SPF/DKIM/DMARC). But there are more signals, for example: Sending to
a mail server or address for the first time. Sending from a newly registered
domain. Sending messages with content that resembles known spam messages.
domain (especially if you're sending automated messages, and if you send more
messages after previous messages were rejected), domains that existed for a few
weeks to a month are treated more friendly. Sending messages with content that
resembles known spam messages.
Should your email be rejected, you will typically get an error message during
the SMTP transaction that explains why. In the case of big email providers the
error message often has instructions on how to prove to them you are a legimate
sender.
error message often has instructions on how to prove to them you are a
legitimate sender.
## Can mox deliver through a smarthost?
Yes, you can configure a "Transport" in mox.conf and configure "Routes" in
domains.conf to send some or all messages through the transport. A transport
can be an SMTP relay or authenticated submission, or making mox make outgoing
connections through a SOCKS proxy.
For an example, see https://www.xmox.nl/config/#hdr-example-transport. For
details about Transports and Routes, see
https://www.xmox.nl/config/#cfg-mox-conf-Transports and
https://www.xmox.nl/config/#cfg-domains-conf-Routes.
Remember to add the IP addresses of the transport to the SPF records of your
domains. Keep in mind some 3rd party submission servers may mishandle your
messages, for example by replacing your Message-Id header and thereby
invalidating your DKIM-signatures, or rejecting messages with more than one
DKIM-signature.
## Can I use mox to send transactional email?
Yes. While you can use SMTP submission to send messages you've composed
yourself, and monitor a mailbox for DSNs, a more convenient option is to use
the mox HTTP/JSON-based webapi and webhooks.
The mox webapi can be used to send outgoing messages that mox composes. The web
api can also be used to deal with messages stored in an account, like changing
message flags, retrieving messages in parsed form or individual parts of
multipart messages, or moving messages to another mailbox or deleting messages
altogether.
Mox webhooks can be used to receive updates about incoming and outgoing
deliveries. Mox can automatically manage per account suppression lists.
See https://www.xmox.nl/features/#hdr-webapi-and-webhooks for details.
## Can I use existing TLS certificates/keys?
@ -426,19 +485,68 @@ Yes. The quickstart command creates a config that uses ACME with Let's Encrypt,
but you can change the config file to use existing certificate and key files.
You'll see "ACME: letsencrypt" in the "TLS" section of the "public" Listener.
Remove or comment out the ACME-line, and add a "KeyCerts" section like in the
example config file in
https://pkg.go.dev/github.com/mjl-/mox/config#hdr-mox_conf. You can have
multiple certificates and keys: The line with the "-" (dash) is the start of a
list item. Duplicate that line up to and including the line with KeyFile for
each certificate/key you have. Mox makes a TLS config that holds all specified
certificates/keys, and uses it for all services for that Listener (including a
webserver), choosing the correct certificate for incoming requests.
Remove or comment out the ACME-line, and add a "KeyCerts" section, see
https://www.xmox.nl/config/#cfg-mox-conf-Listeners-x-TLS-KeyCerts
You can have multiple certificates and keys: The line with the "-" (dash) is
the start of a list item. Duplicate that line up to and including the line with
KeyFile for each certificate/key you have. Mox makes a TLS config that holds
all specified certificates/keys, and uses it for all services for that Listener
(including a webserver), choosing the correct certificate for incoming
requests.
Keep in mind that for each email domain you host, you will need a certificate
for `mta-sts.<domain>` and `autoconfig.<domain>`, unless you disable MTA-STS
and autoconfig for that domain.
for `mta-sts.<domain>`, `autoconfig.<domain>` and `mail.<domain>`, unless you
disable MTA-STS, autoconfig and the client-settings-domain for that domain.
Mox opens the key and certificate files during initial startup, as root (and
passes file descriptors to the unprivileged process). No special permissions
are needed on the key and certificate files.
## Can I directly access mailboxes through the file system?
No, mox only provides access to email through protocols like IMAP.
While it can be convenient for users/email clients to access email through
conventions like Maildir, providing such access puts quite a burden on the
server: The server has to continuously watch for changes made to the mail store
by external programs, and sync its internal state. By only providing access to
emails through mox, the storage/state management is simpler and easier to
implement reliably.
Not providing direct file system access also allows future improvements in the
storage mechanism. Such as encryption of all stored messages. Programs won't be
able to access such messages directly.
Mox stores metadata about delivered messages in its per-account message index
database, more than fits in a simple (filename-based) format like Maildir. The
IP address of the remote SMTP server during delivery, SPF/DKIM/DMARC domains
and validation status, and more...
For efficiency, mox doesn't prepend message headers generated during delivery
(e.g. Authentication-Results) to the on-disk message file, but only stores it
in the database. This prevents a rewrite of the entire message file. When
reading a message, mox combines the prepended headers from the database with
the message file.
Mox user accounts have no relation to operating system user accounts. Multiple
system users reading their email on a single machine is not very common
anymore. All data (for all accounts) stored by mox is accessible only by the
mox process. Messages are currently stored as individual files in standard
Internet Message Format (IMF), at `data/accounts/<account>/msg/<dir>/<msgid>`:
`msgid` is a consecutive unique integer id assigned by the per-account message
index database; `dir` groups 8k consecutive message ids into a directory,
ensuring they don't become too large. The message index database file for an
account is at `data/accounts/<account>/index.db`, accessed with the bstore
database library, which uses bbolt (formerly BoltDB) for storage, a
transactional key/value library/file format inspired by LMDB.
## How do I block IPs with authentication failures with fail2ban?
Mox includes a rate limiter for IPs/networks that cause too many authentication
failures. It automatically unblocks such IPs/networks after a while. So you may
not need fail2ban. If you want to use fail2ban, you could use this snippet:
[Definition]
failregex = .*failed authentication attempt.*remote=<HOST>
ignoreregex =

1158
admin/admin.go Normal file

File diff suppressed because it is too large Load Diff

175
admin/clientconfig.go Normal file
View File

@ -0,0 +1,175 @@
package admin
import (
"fmt"
"maps"
"slices"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
)
type TLSMode uint8
const (
TLSModeImmediate TLSMode = 0
TLSModeSTARTTLS TLSMode = 1
TLSModeNone TLSMode = 2
)
type ProtocolConfig struct {
Host dns.Domain
Port int
TLSMode TLSMode
EnabledOnHTTPS bool
}
type ClientConfig struct {
IMAP ProtocolConfig
Submission ProtocolConfig
}
// ClientConfigDomain returns a single IMAP and Submission client configuration for
// a domain.
func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
var haveIMAP, haveSubmission bool
domConf, ok := mox.Conf.Domain(d)
if !ok {
return ClientConfig{}, fmt.Errorf("%w: unknown domain", ErrRequest)
}
gather := func(l config.Listener) (done bool) {
host := mox.Conf.Static.HostnameDomain
if l.Hostname != "" {
host = l.HostnameDomain
}
if domConf.ClientSettingsDomain != "" {
host = domConf.ClientSettingsDNSDomain
}
if !haveIMAP && l.IMAPS.Enabled {
rconfig.IMAP.Host = host
rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993)
rconfig.IMAP.TLSMode = TLSModeImmediate
rconfig.IMAP.EnabledOnHTTPS = l.IMAPS.EnabledOnHTTPS
haveIMAP = true
}
if !haveIMAP && l.IMAP.Enabled {
rconfig.IMAP.Host = host
rconfig.IMAP.Port = config.Port(l.IMAP.Port, 143)
rconfig.IMAP.TLSMode = TLSModeSTARTTLS
if l.TLS == nil {
rconfig.IMAP.TLSMode = TLSModeNone
}
haveIMAP = true
}
if !haveSubmission && l.Submissions.Enabled {
rconfig.Submission.Host = host
rconfig.Submission.Port = config.Port(l.Submissions.Port, 465)
rconfig.Submission.TLSMode = TLSModeImmediate
rconfig.Submission.EnabledOnHTTPS = l.Submissions.EnabledOnHTTPS
haveSubmission = true
}
if !haveSubmission && l.Submission.Enabled {
rconfig.Submission.Host = host
rconfig.Submission.Port = config.Port(l.Submission.Port, 587)
rconfig.Submission.TLSMode = TLSModeSTARTTLS
if l.TLS == nil {
rconfig.Submission.TLSMode = TLSModeNone
}
haveSubmission = true
}
return haveIMAP && haveSubmission
}
// Look at the public listener first. Most likely the intended configuration.
if public, ok := mox.Conf.Static.Listeners["public"]; ok {
if gather(public) {
return
}
}
// Go through the other listeners in consistent order.
names := slices.Sorted(maps.Keys(mox.Conf.Static.Listeners))
for _, name := range names {
if gather(mox.Conf.Static.Listeners[name]) {
return
}
}
return ClientConfig{}, fmt.Errorf("%w: no listeners found for imap and/or submission", ErrRequest)
}
// ClientConfigs holds the client configuration for IMAP/Submission for a
// domain.
type ClientConfigs struct {
Entries []ClientConfigsEntry
}
type ClientConfigsEntry struct {
Protocol string
Host dns.Domain
Port int
Listener string
Note string
}
// ClientConfigsDomain returns the client configs for IMAP/Submission for a
// domain.
func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
domConf, ok := mox.Conf.Domain(d)
if !ok {
return ClientConfigs{}, fmt.Errorf("%w: unknown domain", ErrRequest)
}
c := ClientConfigs{}
c.Entries = []ClientConfigsEntry{}
var listeners []string
for name := range mox.Conf.Static.Listeners {
listeners = append(listeners, name)
}
slices.Sort(listeners)
note := func(tls bool, requiretls bool) string {
if !tls {
return "plain text, no STARTTLS configured"
}
if requiretls {
return "STARTTLS required"
}
return "STARTTLS optional"
}
for _, name := range listeners {
l := mox.Conf.Static.Listeners[name]
host := mox.Conf.Static.HostnameDomain
if l.Hostname != "" {
host = l.HostnameDomain
}
if domConf.ClientSettingsDomain != "" {
host = domConf.ClientSettingsDNSDomain
}
if l.Submissions.Enabled {
note := "with TLS"
if l.Submissions.EnabledOnHTTPS {
note += "; also served on port 443 with TLS ALPN \"smtp\""
}
c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, note})
}
if l.IMAPS.Enabled {
note := "with TLS"
if l.IMAPS.EnabledOnHTTPS {
note += "; also served on port 443 with TLS ALPN \"imap\""
}
c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, note})
}
if l.Submission.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submission.Port, 587), name, note(l.TLS != nil, !l.Submission.NoRequireSTARTTLS)})
}
if l.IMAP.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)})
}
}
return c, nil
}

318
admin/dnsrecords.go Normal file
View File

@ -0,0 +1,318 @@
package admin
import (
"crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"fmt"
"net/url"
"strings"
"github.com/mjl-/adns"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/spf"
"github.com/mjl-/mox/tlsrpt"
"slices"
)
// todo: find a way to automatically create the dns records as it would greatly simplify setting up email for a domain. we could also dynamically make changes, e.g. providing grace periods after disabling a dkim key, only automatically removing the dkim dns key after a few days. but this requires some kind of api and authentication to the dns server. there doesn't appear to be a single commonly used api for dns management. each of the numerous cloud providers have their own APIs and rather large SKDs to use them. we don't want to link all of them in.
// DomainRecords returns text lines describing DNS records required for configuring
// a domain.
//
// If certIssuerDomainName is set, CAA records to limit TLS certificate issuance to
// that caID will be suggested. If acmeAccountURI is also set, CAA records also
// restricting issuance to that account ID will be suggested.
func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, certIssuerDomainName, acmeAccountURI string) ([]string, error) {
d := domain.ASCII
h := mox.Conf.Static.HostnameDomain.ASCII
// The first line with ";" is used by ../testdata/integration/moxacmepebble.sh and
// ../testdata/integration/moxmail2.sh for selecting DNS records
records := []string{
"; Time To Live of 5 minutes, may be recognized if importing as a zone file.",
"; Once your setup is working, you may want to increase the TTL.",
"$TTL 300",
"",
}
if public, ok := mox.Conf.Static.Listeners["public"]; ok && public.TLS != nil && (len(public.TLS.HostPrivateRSA2048Keys) > 0 || len(public.TLS.HostPrivateECDSAP256Keys) > 0) {
records = append(records,
`; DANE: These records indicate that a remote mail server trying to deliver email`,
`; with SMTP (TCP port 25) must verify the TLS certificate with DANE-EE (3), based`,
`; on the certificate public key ("SPKI", 1) that is SHA2-256-hashed (1) to the`,
`; hexadecimal hash. DANE-EE verification means only the certificate or public`,
`; key is verified, not whether the certificate is signed by a (centralized)`,
`; certificate authority (CA), is expired, or matches the host name.`,
`;`,
`; NOTE: Create the records below only once: They are for the machine, and apply`,
`; to all hosted domains.`,
)
if !hasDNSSEC {
records = append(records,
";",
"; WARNING: Domain does not appear to be DNSSEC-signed. To enable DANE, first",
"; enable DNSSEC on your domain, then add the TLSA records. Records below have been",
"; commented out.",
)
}
addTLSA := func(privKey crypto.Signer) error {
spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public())
if err != nil {
return fmt.Errorf("marshal SubjectPublicKeyInfo for DANE record: %v", err)
}
sum := sha256.Sum256(spkiBuf)
tlsaRecord := adns.TLSA{
Usage: adns.TLSAUsageDANEEE,
Selector: adns.TLSASelectorSPKI,
MatchType: adns.TLSAMatchTypeSHA256,
CertAssoc: sum[:],
}
var s string
if hasDNSSEC {
s = fmt.Sprintf("_25._tcp.%-*s TLSA %s", 20+len(d)-len("_25._tcp."), h+".", tlsaRecord.Record())
} else {
s = fmt.Sprintf(";; _25._tcp.%-*s TLSA %s", 20+len(d)-len(";; _25._tcp."), h+".", tlsaRecord.Record())
}
records = append(records, s)
return nil
}
for _, privKey := range public.TLS.HostPrivateECDSAP256Keys {
if err := addTLSA(privKey); err != nil {
return nil, err
}
}
for _, privKey := range public.TLS.HostPrivateRSA2048Keys {
if err := addTLSA(privKey); err != nil {
return nil, err
}
}
records = append(records, "")
}
if d != h {
records = append(records,
"; For the machine, only needs to be created once, for the first domain added:",
"; ",
"; SPF-allow host for itself, resulting in relaxed DMARC pass for (postmaster)",
"; messages (DSNs) sent from host:",
fmt.Sprintf(`%-*s TXT "v=spf1 a -all"`, 20+len(d), h+"."), // ../rfc/7208:2263 ../rfc/7208:2287
"",
)
}
if d != h && mox.Conf.Static.HostTLSRPT.ParsedLocalpart != "" {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(mox.Conf.Static.HostTLSRPT.ParsedLocalpart, mox.Conf.Static.HostnameDomain).Pack(false),
}
tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
records = append(records,
"; For the machine, only needs to be created once, for the first domain added:",
"; ",
"; Request reporting about success/failures of TLS connections to (MX) host, for DANE.",
fmt.Sprintf(`_smtp._tls.%-*s TXT "%s"`, 20+len(d)-len("_smtp._tls."), h+".", tlsrptr.String()),
"",
)
}
records = append(records,
"; Deliver email for the domain to this host.",
fmt.Sprintf("%s. MX 10 %s.", d, h),
"",
"; Outgoing messages will be signed with the first two DKIM keys. The other two",
"; configured for backup, switching to them is just a config change.",
)
var selectors []string
for name := range domConf.DKIM.Selectors {
selectors = append(selectors, name)
}
slices.Sort(selectors)
for _, name := range selectors {
sel := domConf.DKIM.Selectors[name]
dkimr := dkim.Record{
Version: "DKIM1",
Hashes: []string{"sha256"},
PublicKey: sel.Key.Public(),
}
if _, ok := sel.Key.(ed25519.PrivateKey); ok {
dkimr.Key = "ed25519"
} else if _, ok := sel.Key.(*rsa.PrivateKey); !ok {
return nil, fmt.Errorf("unrecognized private key for DKIM selector %q: %T", name, sel.Key)
}
txt, err := dkimr.Record()
if err != nil {
return nil, fmt.Errorf("making DKIM DNS TXT record: %v", err)
}
if len(txt) > 100 {
records = append(records,
"; NOTE: The following is a single long record split over several lines for use",
"; in zone files. When adding through a DNS operator web interface, combine the",
"; strings into a single string, without ().",
)
}
s := fmt.Sprintf("%s._domainkey.%s. TXT %s", name, d, mox.TXTStrings(txt))
records = append(records, s)
}
dmarcr := dmarc.DefaultRecord
dmarcr.Policy = "reject"
if domConf.DMARC != nil {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
}
dmarcr.AggregateReportAddresses = []dmarc.URI{
{Address: uri.String(), MaxSize: 10, Unit: "m"},
}
}
dspfr := spf.Record{Version: "spf1"}
for _, ip := range mox.DomainSPFIPs() {
mech := "ip4"
if ip.To4() == nil {
mech = "ip6"
}
dspfr.Directives = append(dspfr.Directives, spf.Directive{Mechanism: mech, IP: ip})
}
dspfr.Directives = append(dspfr.Directives,
spf.Directive{Mechanism: "mx"},
spf.Directive{Qualifier: "~", Mechanism: "all"},
)
dspftxt, err := dspfr.Record()
if err != nil {
return nil, fmt.Errorf("making domain spf record: %v", err)
}
records = append(records,
"",
"; Specify the MX host is allowed to send for our domain and for itself (for DSNs).",
"; ~all means softfail for anything else, which is done instead of -all to prevent older",
"; mail servers from rejecting the message because they never get to looking for a dkim/dmarc pass.",
fmt.Sprintf(`%s. TXT "%s"`, d, dspftxt),
"",
"; Emails that fail the DMARC check (without aligned DKIM and without aligned SPF)",
"; should be rejected, and request reports. If you email through mailing lists that",
"; strip DKIM-Signature headers and don't rewrite the From header, you may want to",
"; set the policy to p=none.",
fmt.Sprintf(`_dmarc.%s. TXT "%s"`, d, dmarcr.String()),
"",
)
if sts := domConf.MTASTS; sts != nil {
records = append(records,
"; Remote servers can use MTA-STS to verify our TLS certificate with the",
"; WebPKI pool of CA's (certificate authorities) when delivering over SMTP with",
"; STARTTLS.",
fmt.Sprintf(`mta-sts.%s. CNAME %s.`, d, h),
fmt.Sprintf(`_mta-sts.%s. TXT "v=STSv1; id=%s"`, d, sts.PolicyID),
"",
)
} else {
records = append(records,
"; Note: No MTA-STS to indicate TLS should be used. Either because disabled for the",
"; domain or because mox.conf does not have a listener with MTA-STS configured.",
"",
)
}
if domConf.TLSRPT != nil {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
}
tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
records = append(records,
"; Request reporting about TLS failures.",
fmt.Sprintf(`_smtp._tls.%s. TXT "%s"`, d, tlsrptr.String()),
"",
)
}
if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != mox.Conf.Static.HostnameDomain {
records = append(records,
"; Client settings will reference a subdomain of the hosted domain, making it",
"; easier to migrate to a different server in the future by not requiring settings",
"; in all clients to be updated.",
fmt.Sprintf(`%-*s CNAME %s.`, 20+len(d), domConf.ClientSettingsDNSDomain.ASCII+".", h),
"",
)
}
records = append(records,
"; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h),
fmt.Sprintf(`_autodiscover._tcp.%s. SRV 0 1 443 %s.`, d, h),
"",
// ../rfc/6186:133 ../rfc/8314:692
"; For secure IMAP and submission autoconfig, point to mail host.",
fmt.Sprintf(`_imaps._tcp.%s. SRV 0 1 993 %s.`, d, h),
fmt.Sprintf(`_submissions._tcp.%s. SRV 0 1 465 %s.`, d, h),
"",
// ../rfc/6186:242
"; Next records specify POP3 and non-TLS ports are not to be used.",
"; These are optional and safe to leave out (e.g. if you have to click a lot in a",
"; DNS admin web interface).",
fmt.Sprintf(`_imap._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_submission._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_pop3._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 0 0 .`, d),
)
if certIssuerDomainName != "" {
// ../rfc/8659:18 for CAA records.
records = append(records,
"",
"; Optional:",
"; You could mark Let's Encrypt as the only Certificate Authority allowed to",
"; sign TLS certificates for your domain.",
fmt.Sprintf(`%s. CAA 0 issue "%s"`, d, certIssuerDomainName),
)
if acmeAccountURI != "" {
// ../rfc/8657:99 for accounturi.
// ../rfc/8657:147 for validationmethods.
records = append(records,
";",
"; Optionally limit certificates for this domain to the account ID and methods used by mox.",
fmt.Sprintf(`;; %s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
";",
"; Or alternatively only limit for email-specific subdomains, so you can use",
"; other accounts/methods for other subdomains.",
fmt.Sprintf(`;; autoconfig.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
fmt.Sprintf(`;; mta-sts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
)
if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != mox.Conf.Static.HostnameDomain {
records = append(records,
fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), domConf.ClientSettingsDNSDomain.ASCII, certIssuerDomainName, acmeAccountURI),
)
}
if strings.HasSuffix(h, "."+d) {
records = append(records,
";",
"; And the mail hostname.",
fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), h+".", certIssuerDomainName, acmeAccountURI),
)
}
} else {
// The string "will be suggested" is used by
// ../testdata/integration/moxacmepebble.sh and ../testdata/integration/moxmail2.sh
// as end of DNS records.
records = append(records,
";",
"; Note: After starting up, once an ACME account has been created, CAA records",
"; that restrict issuance to the account will be suggested.",
)
}
}
return records, nil
}

View File

@ -2,24 +2,37 @@
set -e
prevversion=$(go list -mod=readonly -m -f '{{ .Version }}' github.com/mjl-/mox@latest)
nextversion=$(cat next.txt)
if ! test -d tmp/mox-$prevversion; then
mkdir -p tmp/mox-$prevversion
git archive --format=tar v0.0.8 | tar -C tmp/mox-$prevversion -xf -
git archive --format=tar $prevversion | tar -C tmp/mox-$prevversion -xf -
fi
(rm -r tmp/apidiff || exit 0)
mkdir -p tmp/apidiff/$prevversion tmp/apidiff/next
(rm apidiff/$nextversion.txt || exit 0)
(
echo "Below are the incompatible changes between $prevversion and $nextversion, per package."
echo
) >>apidiff/$nextversion.txt
(rm apidiff/next.txt.new 2>/dev/null || exit 0)
touch apidiff/next.txt.new
for p in $(cat apidiff/packages.txt); do
if ! test -d tmp/mox-$prevversion/$p; then
continue
fi
(cd tmp/mox-$prevversion && apidiff -w ../apidiff/$prevversion/$p.api ./$p)
apidiff -w tmp/apidiff/next/$p.api ./$p
apidiff -incompatible tmp/apidiff/$prevversion/$p.api tmp/apidiff/next/$p.api >$p.diff
if test -s $p.diff; then
(
echo '#' $p
apidiff -incompatible tmp/apidiff/$prevversion/$p.api tmp/apidiff/next/$p.api
cat $p.diff
echo
) >>apidiff/$nextversion.txt
) >>apidiff/next.txt.new
fi
rm $p.diff
done
if test -s apidiff/next.txt.new; then
(
echo "Below are the incompatible changes between $prevversion and next, per package."
echo
cat apidiff/next.txt.new
) >apidiff/next.txt
rm apidiff/next.txt.new
else
mv apidiff/next.txt.new apidiff/next.txt
fi

5
apidiff/next.txt Normal file
View File

@ -0,0 +1,5 @@
Below are the incompatible changes between v0.0.15 and next, per package.
# smtpclient
- GatherDestinations: changed from func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.IPDomain) (bool, bool, bool, github.com/mjl-/mox/dns.Domain, []github.com/mjl-/mox/dns.IPDomain, bool, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.IPDomain) (bool, bool, bool, github.com/mjl-/mox/dns.Domain, []HostPref, bool, error)

View File

@ -16,3 +16,5 @@ spf
subjectpass
tlsrpt
updates
webapi
webhook

79
apidiff/v0.0.10.txt Normal file
View File

@ -0,0 +1,79 @@
Below are the incompatible changes between v0.0.9 and v0.0.10, per package.
# dane
- Dial: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, string, string, []github.com/mjl-/adns.TLSAUsage, *crypto/x509.CertPool) (net.Conn, github.com/mjl-/adns.TLSA, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, string, string, []github.com/mjl-/adns.TLSAUsage, *crypto/x509.CertPool) (net.Conn, github.com/mjl-/adns.TLSA, error)
- TLSClientConfig: changed from func(*golang.org/x/exp/slog.Logger, []github.com/mjl-/adns.TLSA, github.com/mjl-/mox/dns.Domain, []github.com/mjl-/mox/dns.Domain, *github.com/mjl-/adns.TLSA, *crypto/x509.CertPool) crypto/tls.Config to func(*log/slog.Logger, []github.com/mjl-/adns.TLSA, github.com/mjl-/mox/dns.Domain, []github.com/mjl-/mox/dns.Domain, *github.com/mjl-/adns.TLSA, *crypto/x509.CertPool) crypto/tls.Config
- Verify: changed from func(*golang.org/x/exp/slog.Logger, []github.com/mjl-/adns.TLSA, crypto/tls.ConnectionState, github.com/mjl-/mox/dns.Domain, []github.com/mjl-/mox/dns.Domain, *crypto/x509.CertPool) (bool, github.com/mjl-/adns.TLSA, error) to func(*log/slog.Logger, []github.com/mjl-/adns.TLSA, crypto/tls.ConnectionState, github.com/mjl-/mox/dns.Domain, []github.com/mjl-/mox/dns.Domain, *crypto/x509.CertPool) (bool, github.com/mjl-/adns.TLSA, error)
# dmarc
- Lookup: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (Status, github.com/mjl-/mox/dns.Domain, *Record, string, bool, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (Status, github.com/mjl-/mox/dns.Domain, *Record, string, bool, error)
- LookupExternalReportsAccepted: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain, github.com/mjl-/mox/dns.Domain) (bool, Status, []*Record, []string, bool, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain, github.com/mjl-/mox/dns.Domain) (bool, Status, []*Record, []string, bool, error)
- Verify: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain, []github.com/mjl-/mox/dkim.Result, github.com/mjl-/mox/spf.Status, *github.com/mjl-/mox/dns.Domain, bool) (bool, Result) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain, []github.com/mjl-/mox/dkim.Result, github.com/mjl-/mox/spf.Status, *github.com/mjl-/mox/dns.Domain, bool) (bool, Result)
# dmarcrpt
- ParseMessageReport: changed from func(*golang.org/x/exp/slog.Logger, io.ReaderAt) (*Feedback, error) to func(*log/slog.Logger, io.ReaderAt) (*Feedback, error)
# dns
- StrictResolver.Log: changed from *golang.org/x/exp/slog.Logger to *log/slog.Logger
# dnsbl
- CheckHealth: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) error to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) error
- Lookup: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain, net.IP) (Status, string, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain, net.IP) (Status, string, error)
# iprev
# message
- (*Part).ParseNextPart: changed from func(*golang.org/x/exp/slog.Logger) (*Part, error) to func(*log/slog.Logger) (*Part, error)
- (*Part).Walk: changed from func(*golang.org/x/exp/slog.Logger, *Part) error to func(*log/slog.Logger, *Part) error
- EnsurePart: changed from func(*golang.org/x/exp/slog.Logger, bool, io.ReaderAt, int64) (Part, error) to func(*log/slog.Logger, bool, io.ReaderAt, int64) (Part, error)
- From: changed from func(*golang.org/x/exp/slog.Logger, bool, io.ReaderAt) (github.com/mjl-/mox/smtp.Address, *Envelope, net/textproto.MIMEHeader, error) to func(*log/slog.Logger, bool, io.ReaderAt) (github.com/mjl-/mox/smtp.Address, *Envelope, net/textproto.MIMEHeader, error)
- Parse: changed from func(*golang.org/x/exp/slog.Logger, bool, io.ReaderAt) (Part, error) to func(*log/slog.Logger, bool, io.ReaderAt) (Part, error)
# mtasts
- FetchPolicy: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Domain) (*Policy, string, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Domain) (*Policy, string, error)
- Get: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (*Record, *Policy, string, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (*Record, *Policy, string, error)
- HTTPClientObserve: changed from func(context.Context, *golang.org/x/exp/slog.Logger, string, string, int, error, time.Time) to func(context.Context, *log/slog.Logger, string, string, int, error, time.Time)
- LookupRecord: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (*Record, string, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (*Record, string, error)
# publicsuffix
- List.Lookup: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Domain) github.com/mjl-/mox/dns.Domain to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Domain) github.com/mjl-/mox/dns.Domain
- Lookup: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Domain) github.com/mjl-/mox/dns.Domain to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Domain) github.com/mjl-/mox/dns.Domain
- ParseList: changed from func(*golang.org/x/exp/slog.Logger, io.Reader) (List, error) to func(*log/slog.Logger, io.Reader) (List, error)
# ratelimit
# sasl
# scram
# smtp
- SePol7ARCFail: removed
- SePol7MissingReqTLS: removed
# smtpclient
- Dial: changed from func(context.Context, *golang.org/x/exp/slog.Logger, Dialer, github.com/mjl-/mox/dns.IPDomain, []net.IP, int, map[string][]net.IP, []net.IP) (net.Conn, net.IP, error) to func(context.Context, *log/slog.Logger, Dialer, github.com/mjl-/mox/dns.IPDomain, []net.IP, int, map[string][]net.IP, []net.IP) (net.Conn, net.IP, error)
- Error: old is comparable, new is not
- GatherDestinations: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.IPDomain) (bool, bool, bool, github.com/mjl-/mox/dns.Domain, []github.com/mjl-/mox/dns.IPDomain, bool, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.IPDomain) (bool, bool, bool, github.com/mjl-/mox/dns.Domain, []github.com/mjl-/mox/dns.IPDomain, bool, error)
- GatherIPs: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.IPDomain, map[string][]net.IP) (bool, bool, github.com/mjl-/mox/dns.Domain, []net.IP, bool, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.IPDomain, map[string][]net.IP) (bool, bool, github.com/mjl-/mox/dns.Domain, []net.IP, bool, error)
- GatherTLSA: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain, bool, github.com/mjl-/mox/dns.Domain) (bool, []github.com/mjl-/adns.TLSA, github.com/mjl-/mox/dns.Domain, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain, bool, github.com/mjl-/mox/dns.Domain) (bool, []github.com/mjl-/adns.TLSA, github.com/mjl-/mox/dns.Domain, error)
- New: changed from func(context.Context, *golang.org/x/exp/slog.Logger, net.Conn, TLSMode, bool, github.com/mjl-/mox/dns.Domain, github.com/mjl-/mox/dns.Domain, Opts) (*Client, error) to func(context.Context, *log/slog.Logger, net.Conn, TLSMode, bool, github.com/mjl-/mox/dns.Domain, github.com/mjl-/mox/dns.Domain, Opts) (*Client, error)
# spf
- Evaluate: changed from func(context.Context, *golang.org/x/exp/slog.Logger, *Record, github.com/mjl-/mox/dns.Resolver, Args) (Status, string, string, bool, error) to func(context.Context, *log/slog.Logger, *Record, github.com/mjl-/mox/dns.Resolver, Args) (Status, string, string, bool, error)
- Lookup: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (Status, string, *Record, bool, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (Status, string, *Record, bool, error)
- Verify: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, Args) (Received, github.com/mjl-/mox/dns.Domain, string, bool, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, Args) (Received, github.com/mjl-/mox/dns.Domain, string, bool, error)
# subjectpass
- Generate: changed from func(*golang.org/x/exp/slog.Logger, github.com/mjl-/mox/smtp.Address, []byte, time.Time) string to func(*log/slog.Logger, github.com/mjl-/mox/smtp.Address, []byte, time.Time) string
- Verify: changed from func(*golang.org/x/exp/slog.Logger, io.ReaderAt, []byte, time.Duration) error to func(*log/slog.Logger, io.ReaderAt, []byte, time.Duration) error
# tlsrpt
- Lookup: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (*Record, string, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (*Record, string, error)
- ParseMessage: changed from func(*golang.org/x/exp/slog.Logger, io.ReaderAt) (*ReportJSON, error) to func(*log/slog.Logger, io.ReaderAt) (*ReportJSON, error)
# updates
- Check: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain, Version, string, []byte) (Version, *Record, *Changelog, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain, Version, string, []byte) (Version, *Record, *Changelog, error)
- FetchChangelog: changed from func(context.Context, *golang.org/x/exp/slog.Logger, string, Version, []byte) (*Changelog, error) to func(context.Context, *log/slog.Logger, string, Version, []byte) (*Changelog, error)
- HTTPClientObserve: changed from func(context.Context, *golang.org/x/exp/slog.Logger, string, string, int, error, time.Time) to func(context.Context, *log/slog.Logger, string, string, int, error, time.Time)
- Lookup: changed from func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (Version, *Record, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (Version, *Record, error)

45
apidiff/v0.0.11.txt Normal file
View File

@ -0,0 +1,45 @@
Below are the incompatible changes between v0.0.10 and v0.0.11, per package.
# dane
# dmarc
- DMARCPolicy: removed
# dmarcrpt
# dns
# dnsbl
# iprev
# message
- (*Composer).TextPart: changed from func(string) ([]byte, string, string) to func(string, string) ([]byte, string, string)
- From: changed from func(*log/slog.Logger, bool, io.ReaderAt) (github.com/mjl-/mox/smtp.Address, *Envelope, net/textproto.MIMEHeader, error) to func(*log/slog.Logger, bool, io.ReaderAt, *Part) (github.com/mjl-/mox/smtp.Address, *Envelope, net/textproto.MIMEHeader, error)
- NewComposer: changed from func(io.Writer, int64) *Composer to func(io.Writer, int64, bool) *Composer
# mtasts
- STSMX: removed
# publicsuffix
# ratelimit
# sasl
# scram
# smtp
- SeMsg6ConversoinUnsupported3: removed
# smtpclient
- GatherIPs: changed from func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.IPDomain, map[string][]net.IP) (bool, bool, github.com/mjl-/mox/dns.Domain, []net.IP, bool, error) to func(context.Context, *log/slog.Logger, github.com/mjl-/mox/dns.Resolver, string, github.com/mjl-/mox/dns.IPDomain, map[string][]net.IP) (bool, bool, github.com/mjl-/mox/dns.Domain, []net.IP, bool, error)
# spf
# subjectpass
# tlsrpt
# updates

43
apidiff/v0.0.12.txt Normal file
View File

@ -0,0 +1,43 @@
Below are the incompatible changes between v0.0.11 and next, per package.
# dane
# dmarc
# dmarcrpt
# dns
# dnsbl
# iprev
# message
- (*HeaderWriter).AddWrap: changed from func([]byte) to func([]byte, bool)
# mtasts
# publicsuffix
# ratelimit
# sasl
# scram
# smtp
# smtpclient
# spf
# subjectpass
# tlsrpt
# updates
# webapi
# webhook

5
apidiff/v0.0.13.txt Normal file
View File

@ -0,0 +1,5 @@
Below are the incompatible changes between v0.0.13 and next, per package.
# webhook
- PartStructure: removed

7
apidiff/v0.0.15.txt Normal file
View File

@ -0,0 +1,7 @@
Below are the incompatible changes between v0.0.14 and next, per package.
# message
- Part.ContentDescription: changed from string to *string
- Part.ContentID: changed from string to *string
- Part.ContentTransferEncoding: changed from string to *string

View File

@ -20,6 +20,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"path/filepath"
@ -29,7 +30,6 @@ import (
"time"
"golang.org/x/crypto/acme"
"golang.org/x/exp/slog"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@ -42,6 +42,24 @@ import (
)
var (
metricMissingServerName = promauto.NewCounter(
prometheus.CounterOpts{
Name: "mox_autotls_missing_servername_total",
Help: "Number of failed TLS connection attempts with missing SNI where no fallback hostname was configured.",
},
)
metricUnknownServerName = promauto.NewCounter(
prometheus.CounterOpts{
Name: "mox_autotls_unknown_servername_total",
Help: "Number of failed TLS connection attempts with an unrecognized SNI name where no fallback hostname was configured.",
},
)
metricCertRequestErrors = promauto.NewCounter(
prometheus.CounterOpts{
Name: "mox_autotls_cert_request_errors_total",
Help: "Number of errors trying to retrieve a certificate for a hostname, possibly ACME verification errors.",
},
)
metricCertput = promauto.NewCounter(
prometheus.CounterOpts{
Name: "mox_autotls_certput_total",
@ -54,7 +72,6 @@ var (
// certificates for allowlisted hosts.
type Manager struct {
ACMETLSConfig *tls.Config // For serving HTTPS on port 443, which is required for certificate requests to succeed.
TLSConfig *tls.Config // For all TLS servers not used for validating ACME requests. Like SMTP and IMAP (including with STARTTLS) and HTTPS on ports other than 443.
Manager *autocert.Manager
shutdown <-chan struct{}
@ -77,7 +94,7 @@ type Manager struct {
// host, or a newly generated key.
//
// When shutdown is closed, no new TLS connections can be created.
func Load(name, acmeDir, contactEmail, directoryURL string, eabKeyID string, eabKey []byte, getPrivateKey func(host string, keyType autocert.KeyType) (crypto.Signer, error), shutdown <-chan struct{}) (*Manager, error) {
func Load(log mlog.Log, name, acmeDir, contactEmail, directoryURL string, eabKeyID string, eabKey []byte, getPrivateKey func(host string, keyType autocert.KeyType) (crypto.Signer, error), shutdown <-chan struct{}) (*Manager, error) {
if directoryURL == "" {
return nil, fmt.Errorf("empty ACME directory URL")
}
@ -90,7 +107,10 @@ func Load(name, acmeDir, contactEmail, directoryURL string, eabKeyID string, eab
var key crypto.Signer
f, err := os.Open(p)
if f != nil {
defer f.Close()
defer func() {
err := f.Close()
log.Check(err, "closing identify key file")
}()
}
if err != nil && os.IsNotExist(err) {
key, err = ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
@ -158,48 +178,111 @@ func Load(name, acmeDir, contactEmail, directoryURL string, eabKeyID string, eab
}
}
loggingGetCertificate := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
log := mlog.New("autotls", nil).WithContext(hello.Context())
// Handle missing SNI to prevent logging an error below.
// At startup, during config initialization, we already adjust the tls config to
// inject the listener hostname if there isn't one in the TLS client hello. This is
// common for SMTP STARTTLS connections, which often do not care about the
// verification of the certificate.
if hello.ServerName == "" {
log.Debug("tls request without sni servername, rejecting", slog.Any("localaddr", hello.Conn.LocalAddr()), slog.Any("supportedprotos", hello.SupportedProtos))
return nil, fmt.Errorf("sni server name required")
}
cert, err := m.GetCertificate(hello)
if err != nil {
if errors.Is(err, errHostNotAllowed) {
log.Debugx("requesting certificate", err, slog.String("host", hello.ServerName))
} else {
log.Errorx("requesting certificate", err, slog.String("host", hello.ServerName))
}
}
return cert, err
}
acmeTLSConfig := *m.TLSConfig()
acmeTLSConfig.GetCertificate = loggingGetCertificate
tlsConfig := tls.Config{
GetCertificate: loggingGetCertificate,
}
a := &Manager{
ACMETLSConfig: &acmeTLSConfig,
TLSConfig: &tlsConfig,
Manager: m,
shutdown: shutdown,
hosts: map[dns.Domain]struct{}{},
}
m.HostPolicy = a.HostPolicy
acmeTLSConfig := *m.TLSConfig()
acmeTLSConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
return a.loggingGetCertificate(hello, dns.Domain{}, false, false)
}
a.ACMETLSConfig = &acmeTLSConfig
return a, nil
}
// loggingGetCertificate is a helper to implement crypto/tls.Config.GetCertificate,
// optionally falling back to a certificate for fallbackHostname in case SNI is
// absent or for an unknown hostname.
func (m *Manager) loggingGetCertificate(hello *tls.ClientHelloInfo, fallbackHostname dns.Domain, fallbackNoSNI, fallbackUnknownSNI bool) (*tls.Certificate, error) {
log := mlog.New("autotls", nil).WithContext(hello.Context()).With(
slog.Any("localaddr", hello.Conn.LocalAddr()),
slog.Any("supportedprotos", hello.SupportedProtos),
slog.String("servername", hello.ServerName),
)
// If we can't find a certificate (depending on fallback parameters), we return a
// nil certificate and nil error, which crypto/tls turns into a TLS alert
// "unrecognized name", which can be interpreted by clients as a hint that they are
// using the wrong hostname, or a certificate is missing. ../rfc/9325:578
// IP addresses for ServerName are not allowed, but happen in practice. If we
// should be lenient (fallbackUnknownSNI), we switch to the fallback hostname,
// otherwise we return an error. We don't want to pass IP addresses to
// GetCertificate because it will return an error for IPv6 addresses.
// ../rfc/6066:367 ../rfc/4366:535
if net.ParseIP(hello.ServerName) != nil {
if fallbackUnknownSNI {
hello.ServerName = fallbackHostname.ASCII
log = log.With(slog.String("servername", hello.ServerName))
} else {
log.Debug("tls request with ip for server name, rejecting")
return nil, fmt.Errorf("invalid ip address for sni server name")
}
}
if hello.ServerName == "" && fallbackNoSNI {
hello.ServerName = fallbackHostname.ASCII
log = log.With(slog.String("servername", hello.ServerName))
}
// Handle missing SNI to prevent logging an error below.
if hello.ServerName == "" {
metricMissingServerName.Inc()
log.Debug("tls request without sni server name, rejecting")
return nil, nil
}
cert, err := m.Manager.GetCertificate(hello)
if err != nil && errors.Is(err, errHostNotAllowed) {
if !fallbackUnknownSNI {
metricUnknownServerName.Inc()
log.Debugx("requesting certificate", err)
return nil, nil
}
// Some legitimate email deliveries over SMTP use an unknown SNI, e.g. a bare
// domain instead of the MX hostname. We "should" return an error, but that would
// break email delivery, so we use the fallback name if it is configured.
// ../rfc/9325:589
log = log.With(slog.String("servername", hello.ServerName))
log.Debug("certificate for unknown hostname, using fallback hostname")
hello.ServerName = fallbackHostname.ASCII
cert, err = m.Manager.GetCertificate(hello)
if err != nil {
metricCertRequestErrors.Inc()
log.Errorx("requesting certificate for fallback hostname", err)
} else {
log.Debug("using certificate for fallback hostname")
}
return cert, err
} else if err != nil {
metricCertRequestErrors.Inc()
log.Errorx("requesting certificate", err)
}
return cert, err
}
// TLSConfig returns a TLS server config that optionally returns a certificate for
// fallbackHostname if no SNI was done, or for an unknown hostname.
//
// If fallbackNoSNI is set, TLS connections without SNI will use a certificate for
// fallbackHostname. Otherwise, connections without SNI will fail with a message
// that no TLS certificate is available.
//
// If fallbackUnknownSNI is set, TLS connections with an SNI hostname that is not
// allowlisted will instead use a certificate for fallbackHostname. Otherwise, such
// TLS connections will fail.
func (m *Manager) TLSConfig(fallbackHostname dns.Domain, fallbackNoSNI, fallbackUnknownSNI bool) *tls.Config {
return &tls.Config{
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
return m.loggingGetCertificate(hello, fallbackHostname, fallbackNoSNI, fallbackUnknownSNI)
},
}
}
// CertAvailable checks whether a non-expired ECDSA certificate is available in the
// cache for host. No other checks than expiration are done.
func (m *Manager) CertAvailable(ctx context.Context, log mlog.Log, host dns.Domain) (bool, error) {
@ -280,12 +363,12 @@ func (m *Manager) SetAllowedHostnames(log mlog.Log, resolver dns.Resolver, hostn
for _, h := range added {
ips, _, err := resolver.LookupIP(ctx, "ip", h.ASCII+".")
if err != nil {
log.Errorx("warning: acme tls cert validation for host may fail due to dns lookup error", err, slog.Any("host", h))
log.Warnx("acme tls cert validation for host may fail due to dns lookup error", err, slog.Any("host", h))
continue
}
for _, ip := range ips {
if _, ok := publicIPstrs[ip.String()]; !ok {
log.Error("warning: acme tls cert validation for host is likely to fail because not all its ips are being listened on",
log.Warn("acme tls cert validation for host is likely to fail because not all its ips are being listened on",
slog.Any("hostname", h),
slog.Any("listenedips", publicIPs),
slog.Any("hostips", ips),

View File

@ -25,7 +25,7 @@ func TestAutotls(t *testing.T) {
getPrivateKey := func(host string, keyType autocert.KeyType) (crypto.Signer, error) {
return nil, fmt.Errorf("not used")
}
m, err := Load("test", "../testdata/autotls", "mox@localhost", "https://localhost/", "", nil, getPrivateKey, shutdown)
m, err := Load(log, "test", "../testdata/autotls", "mox@localhost", "https://localhost/", "", nil, getPrivateKey, shutdown)
if err != nil {
t.Fatalf("load manager: %v", err)
}
@ -82,7 +82,7 @@ func TestAutotls(t *testing.T) {
key0 := m.Manager.Client.Key
m, err = Load("test", "../testdata/autotls", "mox@localhost", "https://localhost/", "", nil, getPrivateKey, shutdown)
m, err = Load(log, "test", "../testdata/autotls", "mox@localhost", "https://localhost/", "", nil, getPrivateKey, shutdown)
if err != nil {
t.Fatalf("load manager again: %v", err)
}
@ -95,7 +95,7 @@ func TestAutotls(t *testing.T) {
t.Fatalf("hostpolicy, got err %v, expected no error", err)
}
m2, err := Load("test2", "../testdata/autotls", "mox@localhost", "https://localhost/", "", nil, nil, shutdown)
m2, err := Load(log, "test2", "../testdata/autotls", "mox@localhost", "https://localhost/", "", nil, nil, shutdown)
if err != nil {
t.Fatalf("load another manager: %v", err)
}

257
backup.go
View File

@ -7,13 +7,15 @@ import (
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/bstore"
"github.com/mjl-/mox/dmarcdb"
@ -25,7 +27,7 @@ import (
"github.com/mjl-/mox/tlsrptdb"
)
func backupctl(ctx context.Context, ctl *ctl) {
func xbackupctl(ctx context.Context, xctl *ctl) {
/* protocol:
> "backup"
> destdir
@ -39,14 +41,14 @@ func backupctl(ctx context.Context, ctl *ctl) {
// "src" or "dst" are incomplete paths relative to the source or destination data
// directories.
dstDataDir := ctl.xread()
verbose := ctl.xread() == "verbose"
dstDir := xctl.xread()
verbose := xctl.xread() == "verbose"
// Set when an error is encountered. At the end, we warn if set.
var incomplete bool
// We'll be writing output, and logging both to mox and the ctl stream.
writer := ctl.writer()
xwriter := xctl.writer()
// Format easily readable output for the user.
formatLog := func(prefix, text string, err error, attrs ...slog.Attr) []byte {
@ -65,10 +67,8 @@ func backupctl(ctx context.Context, ctl *ctl) {
// Log an error to both the mox service as the user running "mox backup".
pkglogx := func(prefix, text string, err error, attrs ...slog.Attr) {
ctl.log.Errorx(text, err, attrs...)
_, werr := writer.Write(formatLog(prefix, text, err, attrs...))
ctl.xcheck(werr, "write to ctl")
xctl.log.Errorx(text, err, attrs...)
xwriter.Write(formatLog(prefix, text, err, attrs...))
}
// Log an error but don't mark backup as failed.
@ -85,15 +85,100 @@ func backupctl(ctx context.Context, ctl *ctl) {
// If verbose is enabled, log to the cli command. Always log as info level.
xvlog := func(text string, attrs ...slog.Attr) {
ctl.log.Info(text, attrs...)
xctl.log.Info(text, attrs...)
if verbose {
_, werr := writer.Write(formatLog("", text, nil, attrs...))
ctl.xcheck(werr, "write to ctl")
xwriter.Write(formatLog("", text, nil, attrs...))
}
}
dstConfigDir := filepath.Join(dstDir, "config")
dstDataDir := filepath.Join(dstDir, "data")
// Warn if directories already exist, will likely cause failures when trying to
// write files that already exist.
if _, err := os.Stat(dstConfigDir); err == nil {
xwarnx("destination config directory already exists", nil, slog.String("configdir", dstConfigDir))
}
if _, err := os.Stat(dstDataDir); err == nil {
xwarnx("destination data directory already exists", nil, slog.String("dir", dstDataDir))
xwarnx("destination data directory already exists", nil, slog.String("datadir", dstDataDir))
}
os.MkdirAll(dstDir, 0770)
os.MkdirAll(dstConfigDir, 0770)
os.MkdirAll(dstDataDir, 0770)
// Copy all files in the config dir.
srcConfigDir := filepath.Clean(mox.ConfigDirPath("."))
err := filepath.WalkDir(srcConfigDir, func(srcPath string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if srcConfigDir == srcPath {
return nil
}
// Trim directory and separator.
relPath := srcPath[len(srcConfigDir)+1:]
destPath := filepath.Join(dstConfigDir, relPath)
if d.IsDir() {
if info, err := os.Stat(srcPath); err != nil {
return fmt.Errorf("stat config dir %s: %v", srcPath, err)
} else if err := os.Mkdir(destPath, info.Mode()&0777); err != nil {
return fmt.Errorf("mkdir %s: %v", destPath, err)
}
return nil
}
if d.Type()&fs.ModeSymlink != 0 {
linkDest, err := os.Readlink(srcPath)
if err != nil {
return fmt.Errorf("reading symlink %s: %v", srcPath, err)
}
if err := os.Symlink(linkDest, destPath); err != nil {
return fmt.Errorf("creating symlink %s: %v", destPath, err)
}
return nil
}
if !d.Type().IsRegular() {
xwarnx("skipping non-regular/dir/symlink file in config dir", nil, slog.String("path", srcPath))
return nil
}
sf, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("open config file %s: %v", srcPath, err)
}
info, err := sf.Stat()
if err != nil {
return fmt.Errorf("stat config file %s: %v", srcPath, err)
}
df, err := os.OpenFile(destPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0777&info.Mode())
if err != nil {
return fmt.Errorf("create destination config file %s: %v", destPath, err)
}
defer func() {
if df != nil {
err := df.Close()
xctl.log.Check(err, "closing file")
}
}()
defer func() {
err := sf.Close()
xctl.log.Check(err, "closing file")
}()
if _, err := io.Copy(df, sf); err != nil {
return fmt.Errorf("copying config file %s to %s: %v", srcPath, destPath, err)
}
if err := df.Close(); err != nil {
return fmt.Errorf("closing destination config file %s: %v", srcPath, err)
}
df = nil
return nil
})
if err != nil {
xerrx("storing config directory", err)
}
srcDataDir := filepath.Clean(mox.DataDirPath("."))
@ -123,7 +208,10 @@ func backupctl(ctx context.Context, ctl *ctl) {
xerrx("open source file (not backed up)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
return
}
defer sf.Close()
defer func() {
err := sf.Close()
xctl.log.Check(err, "closing source file")
}()
ensureDestDir(dstpath)
df, err := os.OpenFile(dstpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
@ -133,7 +221,8 @@ func backupctl(ctx context.Context, ctl *ctl) {
}
defer func() {
if df != nil {
df.Close()
err := df.Close()
xctl.log.Check(err, "closing destination file")
}
}()
if _, err := io.Copy(df, sf); err != nil {
@ -175,18 +264,9 @@ func backupctl(ctx context.Context, ctl *ctl) {
xvlog("backed up directory", slog.String("dir", dir), slog.Duration("duration", time.Since(tmDir)))
}
// Backup a database by copying it in a readonly transaction.
// Always logs on error, so caller doesn't have to, but also returns the error so
// callers can see result.
backupDB := func(db *bstore.DB, path string) (rerr error) {
defer func() {
if rerr != nil {
xerrx("backing up database", rerr, slog.String("path", path))
}
}()
tmDB := time.Now()
// Backup a database by copying it in a readonly transaction. Wrapped by backupDB
// which logs and returns just a bool.
backupDB0 := func(db *bstore.DB, path string) error {
dstpath := filepath.Join(dstDataDir, path)
ensureDestDir(dstpath)
df, err := os.OpenFile(dstpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
@ -195,7 +275,8 @@ func backupctl(ctx context.Context, ctl *ctl) {
}
defer func() {
if df != nil {
df.Close()
err := df.Close()
xctl.log.Check(err, "closing destination database file")
}
}()
err = db.Read(ctx, func(tx *bstore.Tx) error {
@ -220,10 +301,20 @@ func backupctl(ctx context.Context, ctl *ctl) {
if err != nil {
return fmt.Errorf("closing destination database after copy: %v", err)
}
xvlog("backed up database file", slog.String("path", path), slog.Duration("duration", time.Since(tmDB)))
return nil
}
backupDB := func(db *bstore.DB, path string) bool {
start := time.Now()
err := backupDB0(db, path)
if err != nil {
xerrx("backing up database", err, slog.String("path", path), slog.Duration("duration", time.Since(start)))
return false
}
xvlog("backed up database file", slog.String("path", path), slog.Duration("duration", time.Since(start)))
return true
}
// Try to create a hardlink. Fall back to copying the file (e.g. when on different file system).
warnedHardlink := false // We warn once about failing to hardlink.
linkOrCopy := func(srcpath, dstpath string) (bool, error) {
@ -235,7 +326,11 @@ func backupctl(ctx context.Context, ctl *ctl) {
// No point in trying with regular copy, we would warn twice.
return false, err
} else if !warnedHardlink {
xwarnx("creating hardlink to message failed, will be doing regular file copies and not warn again", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
var hardlinkHint string
if runtime.GOOS == "linux" && errors.Is(err, syscall.EXDEV) {
hardlinkHint = " (hint: if running under systemd, ReadWritePaths in mox.service may cause multiple mountpoints; consider merging paths into a single parent directory to prevent cross-device/mountpoint hardlinks)"
}
xwarnx("creating hardlink to message failed, will be doing regular file copies and not warn again"+hardlinkHint, err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
warnedHardlink = true
}
@ -246,7 +341,7 @@ func backupctl(ctx context.Context, ctl *ctl) {
}
defer func() {
err := sf.Close()
ctl.log.Check(err, "closing copied source file")
xctl.log.Check(err, "closing copied source file")
}()
df, err := os.OpenFile(dstpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
@ -256,7 +351,7 @@ func backupctl(ctx context.Context, ctl *ctl) {
defer func() {
if df != nil {
err := df.Close()
ctl.log.Check(err, "closing partial destination file")
xctl.log.Check(err, "closing partial destination file")
}
}()
if _, err := io.Copy(df, sf); err != nil {
@ -273,16 +368,16 @@ func backupctl(ctx context.Context, ctl *ctl) {
// Start making the backup.
tmStart := time.Now()
ctl.log.Print("making backup", slog.String("destdir", dstDataDir))
xctl.log.Print("making backup", slog.String("destdir", dstDataDir))
err := os.MkdirAll(dstDataDir, 0770)
if err != nil {
if err := os.MkdirAll(dstDataDir, 0770); err != nil {
xerrx("creating destination data directory", err)
}
if err := os.WriteFile(filepath.Join(dstDataDir, "moxversion"), []byte(moxvar.Version), 0660); err != nil {
xerrx("writing moxversion", err)
}
backupDB(store.AuthDB, "auth.db")
backupDB(dmarcdb.ReportsDB, "dmarcrpt.db")
backupDB(dmarcdb.EvalDB, "dmarceval.db")
backupDB(mtastsdb.DB, "mtasts.db")
@ -294,7 +389,7 @@ func backupctl(ctx context.Context, ctl *ctl) {
srcAcmeDir := filepath.Join(srcDataDir, "acme")
if _, err := os.Stat(srcAcmeDir); err == nil {
backupDir("acme")
} else if err != nil && !os.IsNotExist(err) {
} else if !os.IsNotExist(err) {
xerrx("copying acme/", err)
}
@ -302,13 +397,13 @@ func backupctl(ctx context.Context, ctl *ctl) {
backupQueue := func(path string) {
tmQueue := time.Now()
if err := backupDB(queue.DB, path); err != nil {
xerrx("queue not backed up", err, slog.String("path", path), slog.Duration("duration", time.Since(tmQueue)))
if !backupDB(queue.DB, path) {
return
}
dstdbpath := filepath.Join(dstDataDir, path)
db, err := bstore.Open(ctx, dstdbpath, &bstore.Options{MustExist: true}, queue.DBTypes...)
opts := bstore.Options{MustExist: true, RegisterLogger: xctl.log.Logger}
db, err := bstore.Open(ctx, dstdbpath, &opts, queue.DBTypes...)
if err != nil {
xerrx("open copied queue database", err, slog.String("dstpath", dstdbpath), slog.Duration("duration", time.Since(tmQueue)))
return
@ -317,17 +412,20 @@ func backupctl(ctx context.Context, ctl *ctl) {
defer func() {
if db != nil {
err := db.Close()
ctl.log.Check(err, "closing new queue db")
xctl.log.Check(err, "closing new queue db")
}
}()
// Link/copy known message files. Warn if files are missing or unexpected
// (though a message file could have been removed just now due to delivery, or a
// new message may have been queued).
// Link/copy known message files. If a message has been removed while we read the
// database, our backup is not consistent and the backup will be marked failed.
tmMsgs := time.Now()
seen := map[string]struct{}{}
var nlinked, ncopied int
var maxID int64
err = bstore.QueryDB[queue.Msg](ctx, db).ForEach(func(m queue.Msg) error {
if m.ID > maxID {
maxID = m.ID
}
mp := store.MessagePath(m.ID)
seen[mp] = struct{}{}
srcpath := filepath.Join(srcDataDir, "queue", mp)
@ -350,7 +448,9 @@ func backupctl(ctx context.Context, ctl *ctl) {
slog.Duration("duration", time.Since(tmMsgs)))
}
// Read through all files in queue directory and warn about anything we haven't handled yet.
// Read through all files in queue directory and warn about anything we haven't
// handled yet. Message files that are newer than we expect from our consistent
// database snapshot are ignored.
tmWalk := time.Now()
srcqdir := filepath.Join(srcDataDir, "queue")
err = filepath.WalkDir(srcqdir, func(srcqpath string, d fs.DirEntry, err error) error {
@ -368,6 +468,12 @@ func backupctl(ctx context.Context, ctl *ctl) {
if p == "index.db" {
return nil
}
// Skip any messages that were added since we started on our consistent snapshot.
// We don't want to cause spurious backup warnings.
if id, err := strconv.ParseInt(filepath.Base(p), 10, 64); err == nil && maxID > 0 && id > maxID && p == store.MessagePath(id) {
return nil
}
qp := filepath.Join("queue", p)
xwarnx("backing up unrecognized file in queue directory", nil, slog.String("path", qp))
backupFile(qp)
@ -384,21 +490,21 @@ func backupctl(ctx context.Context, ctl *ctl) {
backupQueue(filepath.FromSlash("queue/index.db"))
backupAccount := func(acc *store.Account) {
defer acc.Close()
defer func() {
err := acc.Close()
xctl.log.Check(err, "closing account")
}()
tmAccount := time.Now()
// Copy database file.
dbpath := filepath.Join("accounts", acc.Name, "index.db")
err := backupDB(acc.DB, dbpath)
if err != nil {
xerrx("copying account database", err, slog.String("path", dbpath), slog.Duration("duration", time.Since(tmAccount)))
}
backupDB(acc.DB, dbpath)
// todo: should document/check not taking a rlock on account.
// Copy junkfilter files, if configured.
if jf, _, err := acc.OpenJunkFilter(ctx, ctl.log); err != nil {
if jf, _, err := acc.OpenJunkFilter(ctx, xctl.log); err != nil {
if !errors.Is(err, store.ErrNoJunkFilter) {
xerrx("opening junk filter for account (not backed up)", err)
}
@ -408,13 +514,13 @@ func backupctl(ctx context.Context, ctl *ctl) {
backupDB(db, jfpath)
bloompath := filepath.Join("accounts", acc.Name, "junkfilter.bloom")
backupFile(bloompath)
db = nil
err := jf.Close()
ctl.log.Check(err, "closing junkfilter")
xctl.log.Check(err, "closing junkfilter")
}
dstdbpath := filepath.Join(dstDataDir, dbpath)
db, err := bstore.Open(ctx, dstdbpath, &bstore.Options{MustExist: true}, store.DBTypes...)
opts := bstore.Options{MustExist: true, RegisterLogger: xctl.log.Logger}
db, err := bstore.Open(ctx, dstdbpath, &opts, store.DBTypes...)
if err != nil {
xerrx("open copied account database", err, slog.String("dstpath", dstdbpath), slog.Duration("duration", time.Since(tmAccount)))
return
@ -423,17 +529,19 @@ func backupctl(ctx context.Context, ctl *ctl) {
defer func() {
if db != nil {
err := db.Close()
ctl.log.Check(err, "close account database")
xctl.log.Check(err, "close account database")
}
}()
// Link/copy known message files. Warn if files are missing or unexpected (though a
// message file could have been added just now due to delivery, or a message have
// been removed).
// Link/copy known message files.
tmMsgs := time.Now()
seen := map[string]struct{}{}
var maxID int64
var nlinked, ncopied int
err = bstore.QueryDB[store.Message](ctx, db).FilterEqual("Expunged", false).ForEach(func(m store.Message) error {
if m.ID > maxID {
maxID = m.ID
}
mp := store.MessagePath(m.ID)
seen[mp] = struct{}{}
amp := filepath.Join("accounts", acc.Name, "msg", mp)
@ -457,7 +565,18 @@ func backupctl(ctx context.Context, ctl *ctl) {
slog.Duration("duration", time.Since(tmMsgs)))
}
// Read through all files in account directory and warn about anything we haven't handled yet.
eraseIDs := map[int64]struct{}{}
err = bstore.QueryDB[store.MessageErase](ctx, db).ForEach(func(me store.MessageErase) error {
eraseIDs[me.ID] = struct{}{}
return nil
})
if err != nil {
xerrx("listing erased messages", err)
}
// Read through all files in queue directory and warn about anything we haven't
// handled yet. Message files that are newer than we expect from our consistent
// database snapshot are ignored.
tmWalk := time.Now()
srcadir := filepath.Join(srcDataDir, "accounts", acc.Name)
err = filepath.WalkDir(srcadir, func(srcapath string, d fs.DirEntry, err error) error {
@ -475,6 +594,16 @@ func backupctl(ctx context.Context, ctl *ctl) {
if _, ok := seen[mp]; ok {
return nil
}
// Skip any messages that were added since we started on our consistent snapshot,
// or messages that will be erased. We don't want to cause spurious backup
// warnings.
id, err := strconv.ParseInt(l[len(l)-1], 10, 64)
if err == nil && id > maxID && mp == store.MessagePath(id) {
return nil
} else if _, ok := eraseIDs[id]; err == nil && ok {
return nil
}
}
switch p {
case "index.db", "junkfilter.db", "junkfilter.bloom":
@ -503,7 +632,7 @@ func backupctl(ctx context.Context, ctl *ctl) {
// account directories when handling "all other files" below.
accounts := map[string]struct{}{}
for _, accName := range mox.Conf.Accounts() {
acc, err := store.OpenAccount(ctl.log, accName)
acc, err := store.OpenAccount(xctl.log, accName, false)
if err != nil {
xerrx("opening account for copying (will try to copy as regular files later)", err, slog.String("account", accName))
continue
@ -541,7 +670,7 @@ func backupctl(ctx context.Context, ctl *ctl) {
}
switch p {
case "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "ctl":
case "auth.db", "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "ctl":
// Already handled.
return nil
case "lastknownversion": // Optional file, not yet handled.
@ -559,11 +688,11 @@ func backupctl(ctx context.Context, ctl *ctl) {
xvlog("backup finished", slog.Duration("duration", time.Since(tmStart)))
writer.xclose()
xwriter.xclose()
if incomplete {
ctl.xwrite("errors were encountered during backup")
xctl.xwrite("errors were encountered during backup")
} else {
ctl.xwriteok()
xctl.xwriteok()
}
}

View File

@ -5,6 +5,7 @@ import (
"crypto/tls"
"crypto/x509"
"net"
"net/http"
"net/url"
"reflect"
"regexp"
@ -60,20 +61,20 @@ type Static struct {
HostTLSRPT struct {
Account string `sconf-doc:"Account to deliver TLS reports to. Typically same account as for postmaster."`
Mailbox string `sconf-doc:"Mailbox to deliver TLS reports to. Recommended value: TLSRPT."`
Localpart string `sconf-doc:"Localpart at hostname to accept TLS reports at. Recommended value: tls-reports."`
Localpart string `sconf-doc:"Localpart at hostname to accept TLS reports at. Recommended value: tlsreports."`
ParsedLocalpart smtp.Localpart `sconf:"-"`
} `sconf:"optional" sconf-doc:"Destination for per-host TLS reports (TLSRPT). TLS reports can be per recipient domain (for MTA-STS), or per MX host (for DANE). The per-domain TLS reporting configuration is in domains.conf. This is the TLS reporting configuration for this host. If absent, no host-based TLSRPT address is configured, and no host TLSRPT DNS record is suggested."`
InitialMailboxes InitialMailboxes `sconf:"optional" sconf-doc:"Mailboxes to create for new accounts. Inbox is always created. Mailboxes can be given a 'special-use' role, which are understood by most mail clients. If absent/empty, the following mailboxes are created: Sent, Archive, Trash, Drafts and Junk."`
InitialMailboxes InitialMailboxes `sconf:"optional" sconf-doc:"Mailboxes to create for new accounts. Inbox is always created. Mailboxes can be given a 'special-use' role, which are understood by most mail clients. If absent/empty, the following additional mailboxes are created: Sent, Archive, Trash, Drafts and Junk."`
DefaultMailboxes []string `sconf:"optional" sconf-doc:"Deprecated in favor of InitialMailboxes. Mailboxes to create when adding an account. Inbox is always created. If no mailboxes are specified, the following are automatically created: Sent, Archive, Trash, Drafts and Junk."`
Transports map[string]Transport `sconf:"optional" sconf-doc:"Transport are mechanisms for delivering messages. Transports can be referenced from Routes in accounts, domains and the global configuration. There is always an implicit/fallback delivery transport doing direct delivery with SMTP from the outgoing message queue. Transports are typically only configured when using smarthosts, i.e. when delivering through another SMTP server. Zero or one transport methods must be set in a transport, never multiple. When using an external party to send email for a domain, keep in mind you may have to add their IP address to your domain's SPF record, and possibly additional DKIM records."`
// Awkward naming of fields to get intended default behaviour for zero values.
NoOutgoingDMARCReports bool `sconf:"optional" sconf-doc:"Do not send DMARC reports (aggregate only). By default, aggregate reports on DMARC evaluations are sent to domains if their DMARC policy requests them. Reports are sent at whole hours, with a minimum of 1 hour and maximum of 24 hours, rounded up so a whole number of intervals cover 24 hours, aligned at whole days in UTC. Reports are sent from the postmaster@<mailhostname> address."`
NoOutgoingTLSReports bool `sconf:"optional" sconf-doc:"Do not send TLS reports. By default, reports about failed SMTP STARTTLS connections and related MTA-STS/DANE policies are sent to domains if their TLSRPT DNS record requests them. Reports covering a 24 hour UTC interval are sent daily. Reports are sent from the postmaster address of the configured domain the mailhostname is in. If there is no such domain, or it does not have DKIM configured, no reports are sent."`
OutgoingTLSReportsForAllSuccess bool `sconf:"optional" sconf-doc:"Also send TLS reports if there were no SMTP STARTTLS connection failures. By default, reports are only sent when at least one failure occurred. If a report is sent, it does always include the successful connection counts as well."`
QuotaMessageSize int64 `sconf:"optional" sconf-doc:"Default maximum total message size for accounts, only applicable if greater than zero. Can be overridden per account. Attempting to add new messages beyond the maximum size will result in an error. Useful to prevent a single account from filling storage. The quota only applies to the email message files, not to any file system overhead and also not the message index database file (account for approximately 15% overhead)."`
QuotaMessageSize int64 `sconf:"optional" sconf-doc:"Default maximum total message size in bytes for each individual account, only applicable if greater than zero. Can be overridden per account. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage. The quota only applies to the email message files, not to any file system overhead and also not the message index database file (account for approximately 15% overhead)."`
// All IPs that were explicitly listen on for external SMTP. Only set when there
// All IPs that were explicitly listened on for external SMTP. Only set when there
// are no unspecified external SMTP listeners and there is at most one for IPv4 and
// at most one for IPv6. Used for setting the local address when making outgoing
// connections. Those IPs are assumed to be in an SPF record for the domain,
@ -107,19 +108,22 @@ type SpecialUseMailboxes struct {
// Dynamic is the parsed form of domains.conf, and is automatically reloaded when changed.
type Dynamic struct {
Domains map[string]Domain `sconf-doc:"NOTE: This config file is in 'sconf' format. Indent with tabs. Comments must be on their own line, they don't end a line. Do not escape or quote strings. Details: https://pkg.go.dev/github.com/mjl-/sconf.\n\n\nDomains for which email is accepted. For internationalized domains, use their IDNA names in UTF-8."`
Accounts map[string]Account `sconf-doc:"Accounts to which email can be delivered. An account can accept email for multiple domains, for multiple localparts, and deliver to multiple mailboxes."`
Accounts map[string]Account `sconf-doc:"Accounts represent mox users, each with a password and email address(es) to which email can be delivered (possibly at different domains). Each account has its own on-disk directory holding its messages and index database. An account name is not an email address."`
WebDomainRedirects map[string]string `sconf:"optional" sconf-doc:"Redirect all requests from domain (key) to domain (value). Always redirects to HTTPS. For plain HTTP redirects, use a WebHandler with a WebRedirect."`
WebHandlers []WebHandler `sconf:"optional" sconf-doc:"Handle webserver requests by serving static files, redirecting or reverse-proxying HTTP(s). The first matching WebHandler will handle the request. Built-in handlers, e.g. for account, admin, autoconfig and mta-sts always run first. If no handler matches, the response status code is file not found (404). If functionality you need is missng, simply forward the requests to an application that can provide the needed functionality."`
WebHandlers []WebHandler `sconf:"optional" sconf-doc:"Handle webserver requests by serving static files, redirecting, reverse-proxying HTTP(s) or passing the request to an internal service. The first matching WebHandler will handle the request. Built-in system handlers, e.g. for ACME validation, autoconfig and mta-sts always run first. Built-in handlers for admin, account, webmail and webapi are evaluated after all handlers, including webhandlers (allowing for overrides of internal services for some domains). If no handler matches, the response status code is file not found (404). If webserver features are missing, forward the requests to an application that provides the needed functionality itself."`
Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates account routes, domain routes and finally these global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."`
MonitorDNSBLs []string `sconf:"optional" sconf-doc:"DNS blocklists to periodically check with if IPs we send from are present, without using them for checking incoming deliveries.. Also see DNSBLs in SMTP listeners in mox.conf, which specifies DNSBLs to use both for incoming deliveries and for checking our IPs against. Example DNSBLs: sbl.spamhaus.org, bl.spamcop.net."`
WebDNSDomainRedirects map[dns.Domain]dns.Domain `sconf:"-" json:"-"`
MonitorDNSBLZones []dns.Domain `sconf:"-"`
ClientSettingDomains map[dns.Domain]struct{} `sconf:"-" json:"-"`
}
type ACME struct {
DirectoryURL string `sconf-doc:"For letsencrypt, use https://acme-v02.api.letsencrypt.org/directory."`
RenewBefore time.Duration `sconf:"optional" sconf-doc:"How long before expiration to renew the certificate. Default is 30 days."`
ContactEmail string `sconf-doc:"Email address to register at ACME provider. The provider can email you when certificates are about to expire. If you configure an address for which email is delivered by this server, keep in mind that TLS misconfigurations could result in such notification emails not arriving."`
Port int `sconf:"optional" sconf-doc:"TLS port for ACME validation, 443 by default. You should only override this if you cannot listen on port 443 directly. ACME will make requests to port 443, so you'll have to add an external mechanism to get the connection here, e.g. by configuring port forwarding."`
Port int `sconf:"optional" sconf-doc:"TLS port for ACME validation, 443 by default. You should only override this if you cannot listen on port 443 directly. ACME will make requests to port 443, so you'll have to add an external mechanism to get the tls connection here, e.g. by configuring firewall-level port forwarding. Validation over the https port uses tls-alpn-01 with application-layer protocol negotiation, which essentially means the original tls connection must make it here unmodified, an https reverse proxy will not work."`
IssuerDomainName string `sconf:"optional" sconf-doc:"If set, used for suggested CAA DNS records, for restricting TLS certificate issuance to a Certificate Authority. If empty and DirectyURL is for Let's Encrypt, this value is set automatically to letsencrypt.org."`
ExternalAccountBinding *ExternalAccountBinding `sconf:"optional" sconf-doc:"ACME providers can require that a request for a new ACME account reference an existing non-ACME account known to the provider. External account binding references that account by a key id, and authorizes new ACME account requests by signing it with a key known both by the ACME client and ACME provider."`
// ../rfc/8555:2111
@ -133,10 +137,10 @@ type ExternalAccountBinding struct {
}
type Listener struct {
IPs []string `sconf-doc:"Use 0.0.0.0 to listen on all IPv4 and/or :: to listen on all IPv6 addresses, but it is better to explicitly specify the IPs you want to use for email, as mox will make sure outgoing connections will only be made from one of those IPs."`
IPs []string `sconf-doc:"Use 0.0.0.0 to listen on all IPv4 and/or :: to listen on all IPv6 addresses, but it is better to explicitly specify the IPs you want to use for email, as mox will make sure outgoing connections will only be made from one of those IPs. If both outgoing IPv4 and IPv6 connectivity is possible, and only one family has explicitly configured addresses, both address families are still used for outgoing connections. Use the \"direct\" transport to limit address families for outgoing connections."`
NATIPs []string `sconf:"optional" sconf-doc:"If set, the mail server is configured behind a NAT and field IPs are internal instead of the public IPs, while NATIPs lists the public IPs. Used during IP-related DNS self-checks, such as for iprev, mx, spf, autoconfig, autodiscover, and for autotls."`
IPsNATed bool `sconf:"optional" sconf-doc:"Deprecated, use NATIPs instead. If set, IPs are not the public IPs, but are NATed. Skips IP-related DNS self-checks."`
Hostname string `sconf:"optional" sconf-doc:"If empty, the config global Hostname is used."`
Hostname string `sconf:"optional" sconf-doc:"If empty, the config global Hostname is used. The internal services webadmin, webaccount, webmail and webapi only match requests to IPs, this hostname, \"localhost\". All except webadmin also match for any client settings domain."`
HostnameDomain dns.Domain `sconf:"-" json:"-"` // Set when parsing config.
TLS *TLS `sconf:"optional" sconf-doc:"For SMTP/IMAP STARTTLS, direct TLS and HTTPS connections."`
@ -150,10 +154,12 @@ type Listener struct {
// Reoriginated messages (such as messages sent to mailing list subscribers) should
// keep REQUIRETLS. ../rfc/8689:412
DNSBLs []string `sconf:"optional" sconf-doc:"Addresses of DNS block lists for incoming messages. Block lists are only consulted for connections/messages without enough reputation to make an accept/reject decision. This prevents sending IPs of all communications to the block list provider. If any of the listed DNSBLs contains a requested IP address, the message is rejected as spam. The DNSBLs are checked for healthiness before use, at most once per 4 hours. Example DNSBLs: sbl.spamhaus.org, bl.spamcop.net. See https://www.spamhaus.org/sbl/ and https://www.spamcop.net/ for more information and terms of use."`
DNSBLs []string `sconf:"optional" sconf-doc:"Addresses of DNS block lists for incoming messages. Block lists are only consulted for connections/messages without enough reputation to make an accept/reject decision. This prevents sending IPs of all communications to the block list provider. If any of the listed DNSBLs contains a requested IP address, the message is rejected as spam. The DNSBLs are checked for healthiness before use, at most once per 4 hours. IPs we can send from are periodically checked for being in the configured DNSBLs. See MonitorDNSBLs in domains.conf to only monitor IPs we send from, without using those DNSBLs for incoming messages. Example DNSBLs: sbl.spamhaus.org, bl.spamcop.net. See https://www.spamhaus.org/sbl/ and https://www.spamcop.net/ for more information and terms of use."`
FirstTimeSenderDelay *time.Duration `sconf:"optional" sconf-doc:"Delay before accepting a message from a first-time sender for the destination account. Default: 15s."`
TLSSessionTicketsDisabled *bool `sconf:"optional" sconf-doc:"Override default setting for enabling TLS session tickets. Disabling session tickets may work around TLS interoperability issues."`
DNSBLZones []dns.Domain `sconf:"-"`
} `sconf:"optional"`
Submission struct {
@ -164,6 +170,7 @@ type Listener struct {
Submissions struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 465."`
EnabledOnHTTPS bool `sconf:"optional" sconf-doc:"Additionally enable submission on HTTPS port 443 via TLS ALPN. TLS Application Layer Protocol Negotiation allows clients to request a specific protocol from the server as part of the TLS connection setup. When this setting is enabled and a client requests the 'smtp' protocol after TLS, it will be able to talk SMTP to Mox on port 443. This is meant to be useful as a censorship circumvention technique for Delta Chat."`
} `sconf:"optional" sconf-doc:"SMTP over TLS for submitting email, by email applications. Requires a TLS config."`
IMAP struct {
Enabled bool
@ -173,6 +180,7 @@ type Listener struct {
IMAPS struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 993."`
EnabledOnHTTPS bool `sconf:"optional" sconf-doc:"Additionally enable IMAP on HTTPS port 443 via TLS ALPN. TLS Application Layer Protocol Negotiation allows clients to request a specific protocol from the server as part of the TLS connection setup. When this setting is enabled and a client requests the 'imap' protocol after TLS, it will be able to talk IMAP to Mox on port 443. This is meant to be useful as a censorship circumvention technique for Delta Chat."`
} `sconf:"optional" sconf-doc:"IMAP over TLS for reading email, by email applications. Requires a TLS config."`
AccountHTTP WebService `sconf:"optional" sconf-doc:"Account web interface, for email users wanting to change their accounts, e.g. set new password, set new delivery rulesets. Default path is /."`
AccountHTTPS WebService `sconf:"optional" sconf-doc:"Account web interface listener like AccountHTTP, but for HTTPS. Requires a TLS config."`
@ -180,6 +188,8 @@ type Listener struct {
AdminHTTPS WebService `sconf:"optional" sconf-doc:"Admin web interface listener like AdminHTTP, but for HTTPS. Requires a TLS config."`
WebmailHTTP WebService `sconf:"optional" sconf-doc:"Webmail client, for reading email. Default path is /webmail/."`
WebmailHTTPS WebService `sconf:"optional" sconf-doc:"Webmail client, like WebmailHTTP, but for HTTPS. Requires a TLS config."`
WebAPIHTTP WebService `sconf:"optional" sconf-doc:"Like WebAPIHTTP, but with plain HTTP, without TLS."`
WebAPIHTTPS WebService `sconf:"optional" sconf-doc:"WebAPI, a simple HTTP/JSON-based API for email, with HTTPS (requires a TLS config). Default path is /webapi/."`
MetricsHTTP struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 8010."`
@ -201,18 +211,20 @@ type Listener struct {
WebserverHTTP struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Port for plain HTTP (non-TLS) webserver."`
RateLimitDisabled bool `sconf:"optional" sconf-doc:"Disable rate limiting for all requests to this port."`
} `sconf:"optional" sconf-doc:"All configured WebHandlers will serve on an enabled listener."`
WebserverHTTPS struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Port for HTTPS webserver."`
RateLimitDisabled bool `sconf:"optional" sconf-doc:"Disable rate limiting for all requests to this port."`
} `sconf:"optional" sconf-doc:"All configured WebHandlers will serve on an enabled listener. Either ACME must be configured, or for each WebHandler domain a TLS certificate must be configured."`
}
// WebService is an internal web interface: webmail, account, admin.
// WebService is an internal web interface: webmail, webaccount, webadmin, webapi.
type WebService struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 80 for HTTP and 443 for HTTPS."`
Path string `sconf:"optional" sconf-doc:"Path to serve requests on."`
Port int `sconf:"optional" sconf-doc:"Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname matching behaviour."`
Path string `sconf:"optional" sconf-doc:"Path to serve requests on. Should end with a slash, related to cookie paths."`
Forwarded bool `sconf:"optional" sconf-doc:"If set, X-Forwarded-* headers are used for the remote IP address for rate limiting and for the \"secure\" status of cookies."`
}
@ -224,6 +236,8 @@ type Transport struct {
Submission *TransportSMTP `sconf:"optional" sconf-doc:"Submission SMTP over a plain TCP connection (possibly with STARTTLS) to submit email to a remote queue."`
SMTP *TransportSMTP `sconf:"optional" sconf-doc:"SMTP over a plain connection (possibly with STARTTLS), typically for old-fashioned unauthenticated relaying to a remote queue."`
Socks *TransportSocks `sconf:"optional" sconf-doc:"Like regular direct delivery, but makes outgoing connections through a SOCKS proxy."`
Direct *TransportDirect `sconf:"optional" sconf-doc:"Like regular direct delivery, but allows to tweak outgoing connections."`
Fail *TransportFail `sconf:"optional" sconf-doc:"Immediately fails the delivery attempt."`
}
// TransportSMTP delivers messages by "submission" (SMTP, typically
@ -260,62 +274,112 @@ type TransportSocks struct {
Hostname dns.Domain `sconf:"-" json:"-"` // Parsed form of RemoteHostname
}
type TransportDirect struct {
DisableIPv4 bool `sconf:"optional" sconf-doc:"If set, outgoing SMTP connections will *NOT* use IPv4 addresses to connect to remote SMTP servers."`
DisableIPv6 bool `sconf:"optional" sconf-doc:"If set, outgoing SMTP connections will *NOT* use IPv6 addresses to connect to remote SMTP servers."`
IPFamily string `sconf:"-" json:"-"`
}
// TransportFail is a transport that fails all delivery attempts.
type TransportFail struct {
SMTPCode int `sconf:"optional" sconf-doc:"SMTP error code and optional enhanced error code to use for the failure. If empty, 554 is used (transaction failed)."`
SMTPMessage string `sconf:"optional" sconf-doc:"Message to include for the rejection. It will be shown in the DSN."`
// Effective values to use, set when parsing.
Code int `sconf:"-"`
Message string `sconf:"-"`
}
type Domain struct {
Disabled bool `sconf:"optional" sconf-doc:"Disabled domains can be useful during/before migrations. Domains that are disabled can still be configured like normal, including adding addresses using the domain to accounts. However, disabled domains: 1. Do not try to fetch ACME certificates. TLS connections to host names involving the email domain will fail. A TLS certificate for the hostname (that wil be used as MX) itself will be requested. 2. Incoming deliveries over SMTP are rejected with a temporary error '450 4.2.1 recipient domain temporarily disabled'. 3. Submissions over SMTP using an (envelope) SMTP MAIL FROM address or message 'From' address of a disabled domain will be rejected with a temporary error '451 4.3.0 sender domain temporarily disabled'. Note that accounts with addresses at disabled domains can still log in and read email (unless the account itself is disabled)."`
Description string `sconf:"optional" sconf-doc:"Free-form description of domain."`
ClientSettingsDomain string `sconf:"optional" sconf-doc:"Hostname for client settings instead of the mail server hostname. E.g. mail.<domain>. For future migration to another mail operator without requiring all clients to update their settings, it is convenient to have client settings that reference a subdomain of the hosted domain instead of the hostname of the server where the mail is currently hosted. If empty, the hostname of the mail server is used for client configurations."`
ClientSettingsDomain string `sconf:"optional" sconf-doc:"Hostname for client settings instead of the mail server hostname. E.g. mail.<domain>. For future migration to another mail operator without requiring all clients to update their settings, it is convenient to have client settings that reference a subdomain of the hosted domain instead of the hostname of the server where the mail is currently hosted. If empty, the hostname of the mail server is used for client configurations. Unicode name."`
LocalpartCatchallSeparator string `sconf:"optional" sconf-doc:"If not empty, only the string before the separator is used to for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com."`
LocalpartCatchallSeparators []string `sconf:"optional" sconf-doc:"Similar to LocalpartCatchallSeparator, but in case multiple are needed. For example both \"+\" and \"-\". Only of one LocalpartCatchallSeparator or LocalpartCatchallSeparators can be set. If set, the first separator is used to make unique addresses for outgoing SMTP connections with FromIDLoginAddresses."`
LocalpartCaseSensitive bool `sconf:"optional" sconf-doc:"If set, upper/lower case is relevant for email delivery."`
DKIM DKIM `sconf:"optional" sconf-doc:"With DKIM signing, a domain is taking responsibility for (content of) emails it sends, letting receiving mail servers build up a (hopefully positive) reputation of the domain, which can help with mail delivery."`
DMARC *DMARC `sconf:"optional" sconf-doc:"With DMARC, a domain publishes, in DNS, a policy on how other mail servers should handle incoming messages with the From-header matching this domain and/or subdomain (depending on the configured alignment). Receiving mail servers use this to build up a reputation of this domain, which can help with mail delivery. A domain can also publish an email address to which reports about DMARC verification results can be sent by verifying mail servers, useful for monitoring. Incoming DMARC reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."`
MTASTS *MTASTS `sconf:"optional" sconf-doc:"With MTA-STS a domain publishes, in DNS, presence of a policy for using/requiring TLS for SMTP connections. The policy is served over HTTPS."`
MTASTS *MTASTS `sconf:"optional" sconf-doc:"MTA-STS is a mechanism that allows publishing a policy with requirements for WebPKI-verified SMTP STARTTLS connections for email delivered to a domain. Existence of a policy is announced in a DNS TXT record (often unprotected/unverified, MTA-STS's weak spot). If a policy exists, it is fetched with a WebPKI-verified HTTPS request. The policy can indicate that WebPKI-verified SMTP STARTTLS is required, and which MX hosts (optionally with a wildcard pattern) are allowd. MX hosts to deliver to are still taken from DNS (again, not necessarily protected/verified), but messages will only be delivered to domains matching the MX hosts from the published policy. Mail servers look up the MTA-STS policy when first delivering to a domain, then keep a cached copy, periodically checking the DNS record if a new policy is available, and fetching and caching it if so. To update a policy, first serve a new policy with an updated policy ID, then update the DNS record (not the other way around). To remove an enforced policy, publish an updated policy with mode \"none\" for a long enough period so all cached policies have been refreshed (taking DNS TTL and policy max age into account), then remove the policy from DNS, wait for TTL to expire, and stop serving the policy."`
TLSRPT *TLSRPT `sconf:"optional" sconf-doc:"With TLSRPT a domain specifies in DNS where reports about encountered SMTP TLS behaviour should be sent. Useful for monitoring. Incoming TLS reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."`
Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates account routes, these domain routes and finally global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."`
Aliases map[string]Alias `sconf:"optional" sconf-doc:"Aliases that cause messages to be delivered to one or more locally configured addresses. Keys are localparts (encoded, as they appear in email addresses)."`
Domain dns.Domain `sconf:"-" json:"-"`
Domain dns.Domain `sconf:"-"`
ClientSettingsDNSDomain dns.Domain `sconf:"-" json:"-"`
// Set when DMARC and TLSRPT (when set) has an address with different domain (we're
// hosting the reporting), and there are no destination addresses configured for
// the domain. Disables some functionality related to hosting a domain.
ReportsOnly bool `sconf:"-" json:"-"`
LocalpartCatchallSeparatorsEffective []string `sconf:"-"` // Either LocalpartCatchallSeparators, the value of LocalpartCatchallSeparator, or empty.
}
// todo: allow external addresses as members of aliases. we would add messages for them to the queue for outgoing delivery. we should require an admin addresses to which delivery failures will be delivered (locally, and to use in smtp mail from, so dsns go there). also take care to evaluate smtputf8 (if external address requires utf8 and incoming transaction didn't).
// todo: as alternative to PostPublic, allow specifying a list of addresses (dmarc-like verified) that are (the only addresses) allowed to post to the list. if msgfrom is an external address, require a valid dkim signature to prevent dmarc-policy-related issues when delivering to remote members.
// todo: add option to require messages sent to an alias have that alias as From or Reply-To address?
type Alias struct {
Addresses []string `sconf-doc:"Expanded addresses to deliver to. These must currently be of addresses of local accounts. To prevent duplicate messages, a member address that is also an explicit recipient in the SMTP transaction will only have the message delivered once. If the address in the message From header is a member, that member also won't receive the message."`
PostPublic bool `sconf:"optional" sconf-doc:"If true, anyone can send messages to the list. Otherwise only members, based on message From address, which is assumed to be DMARC-like-verified."`
ListMembers bool `sconf:"optional" sconf-doc:"If true, members can see addresses of members."`
AllowMsgFrom bool `sconf:"optional" sconf-doc:"If true, members are allowed to send messages with this alias address in the message From header."`
LocalpartStr string `sconf:"-"` // In encoded form.
Domain dns.Domain `sconf:"-"`
ParsedAddresses []AliasAddress `sconf:"-"` // Matches addresses.
}
type AliasAddress struct {
Address smtp.Address // Parsed address.
AccountName string // Looked up.
Destination Destination // Belonging to address.
}
type DMARC struct {
Localpart string `sconf-doc:"Address-part before the @ that accepts DMARC reports. Must be non-internationalized. Recommended value: dmarc-reports."`
Domain string `sconf:"optional" sconf-doc:"Alternative domain for report recipient address. Can be used to receive reports for other domains. Unicode name."`
Localpart string `sconf-doc:"Address-part before the @ that accepts DMARC reports. Must be non-internationalized. Recommended value: dmarcreports."`
Domain string `sconf:"optional" sconf-doc:"Alternative domain for reporting address, for incoming reports. Typically empty, causing the domain wherein this config exists to be used. Can be used to receive reports for domains that aren't fully hosted on this server. Configure such a domain as a hosted domain without making all the DNS changes, and configure this field with a domain that is fully hosted on this server, so the localpart and the domain of this field form a reporting address. Then only update the DMARC DNS record for the not fully hosted domain, ensuring the reporting address is specified in its \"rua\" field as shown in the suggested DNS settings. Unicode name."`
Account string `sconf-doc:"Account to deliver to."`
Mailbox string `sconf-doc:"Mailbox to deliver to, e.g. DMARC."`
ParsedLocalpart smtp.Localpart `sconf:"-"`
ParsedLocalpart smtp.Localpart `sconf:"-"` // Lower-case if case-sensitivity is not configured for domain. Not "canonical" for catchall separators for backwards compatibility.
DNSDomain dns.Domain `sconf:"-"` // Effective domain, always set based on Domain field or Domain where this is configured.
}
type MTASTS struct {
PolicyID string `sconf-doc:"Policies are versioned. The version must be specified in the DNS record. If you change a policy, first change it in mox, then update the DNS record."`
Mode mtasts.Mode `sconf-doc:"testing, enforce or none. If set to enforce, a remote SMTP server will not deliver email to us if it cannot make a TLS connection."`
PolicyID string `sconf-doc:"Policies are versioned. The version must be specified in the DNS record. If you change a policy, first change it here to update the served policy, then update the DNS record with the updated policy ID."`
Mode mtasts.Mode `sconf-doc:"If set to \"enforce\", a remote SMTP server will not deliver email to us if it cannot make a WebPKI-verified SMTP STARTTLS connection. In mode \"testing\", deliveries can be done without verified TLS, but errors will be reported through TLS reporting. In mode \"none\", verified TLS is not required, used for phasing out an MTA-STS policy."`
MaxAge time.Duration `sconf-doc:"How long a remote mail server is allowed to cache a policy. Typically 1 or several weeks."`
MX []string `sconf:"optional" sconf-doc:"List of server names allowed for SMTP. If empty, the configured hostname is set. Host names can contain a wildcard (*) as a leading label (matching a single label, e.g. *.example matches host.example, not sub.host.example)."`
// todo: parse mx as valid mtasts.Policy.MX, with dns.ParseDomain but taking wildcard into account
}
type TLSRPT struct {
Localpart string `sconf-doc:"Address-part before the @ that accepts TLSRPT reports. Recommended value: tls-reports."`
Domain string `sconf:"optional" sconf-doc:"Alternative domain for report recipient address. Can be used to receive reports for other domains. Unicode name."`
Localpart string `sconf-doc:"Address-part before the @ that accepts TLSRPT reports. Recommended value: tlsreports."`
Domain string `sconf:"optional" sconf-doc:"Alternative domain for reporting address, for incoming reports. Typically empty, causing the domain wherein this config exists to be used. Can be used to receive reports for domains that aren't fully hosted on this server. Configure such a domain as a hosted domain without making all the DNS changes, and configure this field with a domain that is fully hosted on this server, so the localpart and the domain of this field form a reporting address. Then only update the TLSRPT DNS record for the not fully hosted domain, ensuring the reporting address is specified in its \"rua\" field as shown in the suggested DNS settings. Unicode name."`
Account string `sconf-doc:"Account to deliver to."`
Mailbox string `sconf-doc:"Mailbox to deliver to, e.g. TLSRPT."`
ParsedLocalpart smtp.Localpart `sconf:"-"`
ParsedLocalpart smtp.Localpart `sconf:"-"` // Lower-case if case-sensitivity is not configured for domain. Not "canonical" for catchall separators for backwards compatibility.
DNSDomain dns.Domain `sconf:"-"` // Effective domain, always set based on Domain field or Domain where this is configured.
}
type Selector struct {
Hash string `sconf:"optional" sconf-doc:"sha256 (default) or (older, not recommended) sha1"`
HashEffective string `sconf:"-"`
Canonicalization struct {
type Canonicalization struct {
HeaderRelaxed bool `sconf-doc:"If set, some modifications to the headers (mostly whitespace) are allowed."`
BodyRelaxed bool `sconf-doc:"If set, some whitespace modifications to the message body are allowed."`
} `sconf:"optional"`
}
type Selector struct {
Hash string `sconf:"optional" sconf-doc:"sha256 (default) or (older, not recommended) sha1."`
HashEffective string `sconf:"-"`
Canonicalization Canonicalization `sconf:"optional"`
Headers []string `sconf:"optional" sconf-doc:"Headers to sign with DKIM. If empty, a reasonable default set of headers is selected."`
HeadersEffective []string `sconf:"-"` // Used when signing. Based on Headers from config, or the reasonable default.
DontSealHeaders bool `sconf:"optional" sconf-doc:"If set, don't prevent duplicate headers from being added. Not recommended."`
Expiration string `sconf:"optional" sconf-doc:"Period a signature is valid after signing, as duration, e.g. 72h. The period should be enough for delivery at the final destination, potentially with several hops/relays. In the order of days at least."`
PrivateKeyFile string `sconf-doc:"Either an RSA or ed25519 private key file in PKCS8 PEM form."`
Algorithm string `sconf:"-"` // "ed25519", "rsa-*", based on private key.
ExpirationSeconds int `sconf:"-" json:"-"` // Parsed from Expiration.
Key crypto.Signer `sconf:"-" json:"-"` // As parsed with x509.ParsePKCS8PrivateKey.
Domain dns.Domain `sconf:"-" json:"-"` // Of selector only, not FQDN.
@ -341,32 +405,66 @@ type Route struct {
// todo: move RejectsMailbox to store.Mailbox.SpecialUse, possibly with "X" prefix?
type Account struct {
Domain string `sconf-doc:"Default domain for account. Deprecated behaviour: If a destination is not a full address but only a localpart, this domain is added to form a full address."`
Description string `sconf:"optional" sconf-doc:"Free form description, e.g. full name or alternative contact info."`
FullName string `sconf:"optional" sconf-doc:"Full name, to use in message From header when composing messages in webmail. Can be overridden per destination."`
Destinations map[string]Destination `sconf-doc:"Destinations, keys are email addresses (with IDNA domains). If the address is of the form '@domain', i.e. with localpart missing, it serves as a catchall for the domain, matching all messages that are not explicitly configured. Deprecated behaviour: If the address is not a full address but a localpart, it is combined with Domain to form a full address."`
SubjectPass struct {
// note: outgoing hook events are in ../queue/hooks.go, ../mox-/config.go, ../queue.go and ../webapi/gendoc.sh. keep in sync.
type OutgoingWebhook struct {
URL string `sconf-doc:"URL to POST webhooks."`
Authorization string `sconf:"optional" sconf-doc:"If not empty, value of Authorization header to add to HTTP requests."`
Events []string `sconf:"optional" sconf-doc:"Events to send outgoing delivery notifications for. If absent, all events are sent. Valid values: delivered, suppressed, delayed, failed, relayed, expanded, canceled, unrecognized."`
}
type IncomingWebhook struct {
URL string `sconf-doc:"URL to POST webhooks to for incoming deliveries over SMTP."`
Authorization string `sconf:"optional" sconf-doc:"If not empty, value of Authorization header to add to HTTP requests."`
}
type SubjectPass struct {
Period time.Duration `sconf-doc:"How long unique values are accepted after generating, e.g. 12h."` // todo: have a reasonable default for this?
} `sconf:"optional" sconf-doc:"If configured, messages classified as weakly spam are rejected with instructions to retry delivery, but this time with a signed token added to the subject. During the next delivery attempt, the signed token will bypass the spam filter. Messages with a clear spam signal, such as a known bad reputation, are rejected/delayed without a signed token."`
QuotaMessageSize int64 `sconf:"optional" sconf-doc:"Default maximum total message size for the account, overriding any globally configured maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages beyond the maximum size will result in an error. Useful to prevent a single account from filling storage."`
RejectsMailbox string `sconf:"optional" sconf-doc:"Mail that looks like spam will be rejected, but a copy can be stored temporarily in a mailbox, e.g. Rejects. If mail isn't coming in when you expect, you can look there. The mail still isn't accepted, so the remote mail server may retry (hopefully, if legitimate), or give up (hopefully, if indeed a spammer). Messages are automatically removed from this mailbox, so do not set it to a mailbox that has messages you want to keep."`
KeepRejects bool `sconf:"optional" sconf-doc:"Don't automatically delete mail in the RejectsMailbox listed above. This can be useful, e.g. for future spam training."`
AutomaticJunkFlags struct {
Enabled bool `sconf-doc:"If enabled, flags will be set automatically if they match a regular expression below. When two of the three mailbox regular expressions are set, the remaining one will match all unmatched messages. Messages are matched in the order specified and the search stops on the first match. Mailboxes are lowercased before matching."`
}
type AutomaticJunkFlags struct {
Enabled bool `sconf-doc:"If enabled, junk/nonjunk flags will be set automatically if they match some of the regular expressions. When two of the three mailbox regular expressions are set, the remaining one will match all unmatched messages. Messages are matched in the order 'junk', 'neutral', 'not junk', and the search stops on the first match. Mailboxes are lowercased before matching."`
JunkMailboxRegexp string `sconf:"optional" sconf-doc:"Example: ^(junk|spam)."`
NeutralMailboxRegexp string `sconf:"optional" sconf-doc:"Example: ^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects), and you may wish to add trash depending on how you use it, or leave this empty."`
NotJunkMailboxRegexp string `sconf:"optional" sconf-doc:"Example: .* or an empty string."`
} `sconf:"optional" sconf-doc:"Automatically set $Junk and $NotJunk flags based on mailbox messages are delivered/moved/copied to. Email clients typically have too limited functionality to conveniently set these flags, especially $NonJunk, but they can all move messages to a different mailbox, so this helps them."`
}
type Account struct {
OutgoingWebhook *OutgoingWebhook `sconf:"optional" sconf-doc:"Webhooks for events about outgoing deliveries."`
IncomingWebhook *IncomingWebhook `sconf:"optional" sconf-doc:"Webhooks for events about incoming deliveries over SMTP."`
FromIDLoginAddresses []string `sconf:"optional" sconf-doc:"Login addresses that cause outgoing email to be sent with SMTP MAIL FROM addresses with a unique id after the localpart catchall separator (which must be enabled when addresses are specified here). Any delivery status notifications (DSN, e.g. for bounces), can be related to the original message and recipient with unique id's. You can login to an account with any valid email address, including variants with the localpart catchall separator. You can use this mechanism to both send outgoing messages with and without unique fromid for a given email address. With the webapi and webmail, a unique id will be generated. For submission, the id from the SMTP MAIL FROM command is used if present, and a unique id is generated otherwise."`
KeepRetiredMessagePeriod time.Duration `sconf:"optional" sconf-doc:"Period to keep messages retired from the queue (delivered or failed) around. Keeping retired messages is useful for maintaining the suppression list for transactional email, for matching incoming DSNs to sent messages, and for debugging. The time at which to clean up (remove) is calculated at retire time. E.g. 168h (1 week)."`
KeepRetiredWebhookPeriod time.Duration `sconf:"optional" sconf-doc:"Period to keep webhooks retired from the queue (delivered or failed) around. Useful for debugging. The time at which to clean up (remove) is calculated at retire time. E.g. 168h (1 week)."`
LoginDisabled string `sconf:"optional" sconf-doc:"If non-empty, login attempts on all protocols (e.g. SMTP/IMAP, web interfaces) is rejected with this error message. Useful during migrations. Incoming deliveries for addresses of this account are still accepted as normal."`
Domain string `sconf-doc:"Default domain for account. Deprecated behaviour: If a destination is not a full address but only a localpart, this domain is added to form a full address."`
Description string `sconf:"optional" sconf-doc:"Free form description, e.g. full name or alternative contact info."`
FullName string `sconf:"optional" sconf-doc:"Full name, to use in message From header when composing messages in webmail. Can be overridden per destination."`
Destinations map[string]Destination `sconf:"optional" sconf-doc:"Destinations, keys are email addresses (with IDNA domains). All destinations are allowed for logging in with IMAP/SMTP/webmail. If no destinations are configured, the account can not login. If the address is of the form '@domain', i.e. with localpart missing, it serves as a catchall for the domain, matching all messages that are not explicitly configured. Deprecated behaviour: If the address is not a full address but a localpart, it is combined with Domain to form a full address."`
SubjectPass SubjectPass `sconf:"optional" sconf-doc:"If configured, messages classified as weakly spam are rejected with instructions to retry delivery, but this time with a signed token added to the subject. During the next delivery attempt, the signed token will bypass the spam filter. Messages with a clear spam signal, such as a known bad reputation, are rejected/delayed without a signed token."`
QuotaMessageSize int64 `sconf:"optional" sconf-doc:"Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage."`
RejectsMailbox string `sconf:"optional" sconf-doc:"Mail that looks like spam will be rejected, but a copy can be stored temporarily in a mailbox, e.g. Rejects. If mail isn't coming in when you expect, you can look there. The mail still isn't accepted, so the remote mail server may retry (hopefully, if legitimate), or give up (hopefully, if indeed a spammer). Messages are automatically removed from this mailbox, so do not set it to a mailbox that has messages you want to keep."`
KeepRejects bool `sconf:"optional" sconf-doc:"Don't automatically delete mail in the RejectsMailbox listed above. This can be useful, e.g. for future spam training. It can also cause storage to fill up."`
AutomaticJunkFlags AutomaticJunkFlags `sconf:"optional" sconf-doc:"Automatically set $Junk and $NotJunk flags based on mailbox messages are delivered/moved/copied to. Email clients typically have too limited functionality to conveniently set these flags, especially $NonJunk, but they can all move messages to a different mailbox, so this helps them."`
JunkFilter *JunkFilter `sconf:"optional" sconf-doc:"Content-based filtering, using the junk-status of individual messages to rank words in such messages as spam or ham. It is recommended you always set the applicable (non)-junk status on messages, and that you do not empty your Trash because those messages contain valuable ham/spam training information."` // todo: sane defaults for junkfilter
MaxOutgoingMessagesPerDay int `sconf:"optional" sconf-doc:"Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000."`
MaxFirstTimeRecipientsPerDay int `sconf:"optional" sconf-doc:"Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200."`
NoFirstTimeSenderDelay bool `sconf:"optional" sconf-doc:"Do not apply a delay to SMTP connections before accepting an incoming message from a first-time sender. Can be useful for accounts that sends automated responses and want instant replies."`
NoCustomPassword bool `sconf:"optional" sconf-doc:"If set, this account cannot set a password of their own choice, but can only set a new randomly generated password, preventing password reuse across services and use of weak passwords. Custom account passwords can be set by the admin."`
Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates these account routes, domain routes and finally global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."`
DNSDomain dns.Domain `sconf:"-"` // Parsed form of Domain.
JunkMailbox *regexp.Regexp `sconf:"-" json:"-"`
NeutralMailbox *regexp.Regexp `sconf:"-" json:"-"`
NotJunkMailbox *regexp.Regexp `sconf:"-" json:"-"`
ParsedFromIDLoginAddresses []smtp.Address `sconf:"-" json:"-"`
Aliases []AddressAlias `sconf:"-"`
}
type AddressAlias struct {
SubscriptionAddress string
Alias Alias // Without members.
MemberAddresses []string // Only if allowed to see.
}
type JunkFilter struct {
@ -377,11 +475,17 @@ type JunkFilter struct {
type Destination struct {
Mailbox string `sconf:"optional" sconf-doc:"Mailbox to deliver to if none of Rulesets match. Default: Inbox."`
Rulesets []Ruleset `sconf:"optional" sconf-doc:"Delivery rules based on message and SMTP transaction. You may want to match each mailing list by SMTP MailFrom address, VerifiedDomain and/or List-ID header (typically <listname.example.org> if the list address is listname@example.org), delivering them to their own mailbox."`
SMTPError string `sconf:"optional" sconf-doc:"If non-empty, incoming delivery attempts to this destination will be rejected during SMTP RCPT TO with this error response line. Useful when a catchall address is configured for the domain and messages to some addresses should be rejected. The response line must start with an error code. Currently the following error resonse codes are allowed: 421 (temporary local error), 550 (user not found). If the line consists of only an error code, an appropriate error message is added. Rejecting messages with a 4xx code invites later retries by the remote, while 5xx codes should prevent further delivery attempts."`
MessageAuthRequiredSMTPError string `sconf:"optional" sconf-doc:"If non-empty, an additional DMARC-like message authentication check is done for incoming messages, validating the domain in the From-header of the message. Messages without either an aligned SPF or aligned DKIM pass are rejected during the SMTP DATA command with a permanent error code followed by the message in this field. The domain in the message 'From' header is matched in relaxed or strict mode according to the domain's DMARC policy if present, or relaxed mode (organizational instead of exact domain match) otherwise. Useful for autoresponders that don't want to accept messages they don't want to send an automated reply to."`
FullName string `sconf:"optional" sconf-doc:"Full name to use in message From header when composing messages coming from this address with webmail."`
DMARCReports bool `sconf:"-" json:"-"`
HostTLSReports bool `sconf:"-" json:"-"`
DomainTLSReports bool `sconf:"-" json:"-"`
// Ready to use in SMTP responses.
SMTPErrorCode int `sconf:"-" json:"-"`
SMTPErrorSecode string `sconf:"-" json:"-"`
SMTPErrorMsg string `sconf:"-" json:"-"`
}
// Equal returns whether d and o are equal, only looking at their user-changeable fields.
@ -399,18 +503,21 @@ func (d Destination) Equal(o Destination) bool {
type Ruleset struct {
SMTPMailFromRegexp string `sconf:"optional" sconf-doc:"Matches if this regular expression matches (a substring of) the SMTP MAIL FROM address (not the message From-header). E.g. '^user@example\\.org$'."`
MsgFromRegexp string `sconf:"optional" sconf-doc:"Matches if this regular expression matches (a substring of) the single address in the message From header."`
VerifiedDomain string `sconf:"optional" sconf-doc:"Matches if this domain matches an SPF- and/or DKIM-verified (sub)domain."`
HeadersRegexp map[string]string `sconf:"optional" sconf-doc:"Matches if these header field/value regular expressions all match (substrings of) the message headers. Header fields and valuees are converted to lower case before matching. Whitespace is trimmed from the value before matching. A header field can occur multiple times in a message, only one instance has to match. For mailing lists, you could match on ^list-id$ with the value typically the mailing list address in angled brackets with @ replaced with a dot, e.g. <name\\.lists\\.example\\.org>."`
// todo: add a SMTPRcptTo check, and MessageFrom that works on a properly parsed From header.
// todo: add a SMTPRcptTo check
// todo: once we implement ARC, we can use dkim domains that we cannot verify but that the arc-verified forwarding mail server was able to verify.
IsForward bool `sconf:"optional" sconf-doc:"Influences spam filtering only, this option does not change whether a message matches this ruleset. Can only be used together with SMTPMailFromRegexp and VerifiedDomain. SMTPMailFromRegexp must be set to the address used to deliver the forwarded message, e.g. '^user(|\\+.*)@forward\\.example$'. Changes to junk analysis: 1. Messages are not rejects for failing a DMARC policy, because a legitimate forwarded message without valid/intact/aligned DKIM signature would be rejected because any verified SPF domain will be 'unaligned', of the forwarding mail server. 2. The sending mail server IP address, and sending EHLO and MAIL FROM domains and matching DKIM domain aren't used in future reputation-based spam classifications (but other verified DKIM domains are) because the forwarding server is not a useful spam signal for future messages."`
IsForward bool `sconf:"optional" sconf-doc:"Influences spam filtering only, this option does not change whether a message matches this ruleset. Can only be used together with SMTPMailFromRegexp and VerifiedDomain. SMTPMailFromRegexp must be set to the address used to deliver the forwarded message, e.g. '^user(|\\+.*)@forward\\.example$'. Changes to junk analysis: 1. Messages are not rejected for failing a DMARC policy, because a legitimate forwarded message without valid/intact/aligned DKIM signature would be rejected because any verified SPF domain will be 'unaligned', of the forwarding mail server. 2. The sending mail server IP address, and sending EHLO and MAIL FROM domains and matching DKIM domain aren't used in future reputation-based spam classifications (but other verified DKIM domains are) because the forwarding server is not a useful spam signal for future messages."`
ListAllowDomain string `sconf:"optional" sconf-doc:"Influences spam filtering only, this option does not change whether a message matches this ruleset. If this domain matches an SPF- and/or DKIM-verified (sub)domain, the message is accepted without further spam checks, such as a junk filter or DMARC reject evaluation. DMARC rejects should not apply for mailing lists that are not configured to rewrite the From-header of messages that don't have a passing DKIM signature of the From-domain. Otherwise, by rejecting messages, you may be automatically unsubscribed from the mailing list. The assumption is that mailing lists do their own spam filtering/moderation."`
AcceptRejectsToMailbox string `sconf:"optional" sconf-doc:"Influences spam filtering only, this option does not change whether a message matches this ruleset. If a message is classified as spam, it isn't rejected during the SMTP transaction (the normal behaviour), but accepted during the SMTP transaction and delivered to the specified mailbox. The specified mailbox is not automatically cleaned up like the account global Rejects mailbox, unless set to that Rejects mailbox."`
Mailbox string `sconf-doc:"Mailbox to deliver to if this ruleset matches."`
Comment string `sconf:"optional" sconf-doc:"Free-form comments."`
SMTPMailFromRegexpCompiled *regexp.Regexp `sconf:"-" json:"-"`
MsgFromRegexpCompiled *regexp.Regexp `sconf:"-" json:"-"`
VerifiedDNSDomain dns.Domain `sconf:"-"`
HeadersRegexpCompiled [][2]*regexp.Regexp `sconf:"-" json:"-"`
ListAllowDNSDomain dns.Domain `sconf:"-"`
@ -418,7 +525,7 @@ type Ruleset struct {
// Equal returns whether r and o are equal, only looking at their user-changeable fields.
func (r Ruleset) Equal(o Ruleset) bool {
if r.SMTPMailFromRegexp != o.SMTPMailFromRegexp || r.VerifiedDomain != o.VerifiedDomain || r.IsForward != o.IsForward || r.ListAllowDomain != o.ListAllowDomain || r.AcceptRejectsToMailbox != o.AcceptRejectsToMailbox || r.Mailbox != o.Mailbox {
if r.SMTPMailFromRegexp != o.SMTPMailFromRegexp || r.MsgFromRegexp != o.MsgFromRegexp || r.VerifiedDomain != o.VerifiedDomain || r.IsForward != o.IsForward || r.ListAllowDomain != o.ListAllowDomain || r.AcceptRejectsToMailbox != o.AcceptRejectsToMailbox || r.Mailbox != o.Mailbox || r.Comment != o.Comment {
return false
}
if !reflect.DeepEqual(r.HeadersRegexp, o.HeadersRegexp) {
@ -437,22 +544,27 @@ type TLS struct {
KeyCerts []KeyCert `sconf:"optional" sconf-doc:"Keys and certificates to use for this listener. The files are opened by the privileged root process and passed to the unprivileged mox process, so no special permissions are required on the files. If the private key will not be replaced when refreshing certificates, also consider adding the private key to HostPrivateKeyFiles and configuring DANE TLSA DNS records."`
MinVersion string `sconf:"optional" sconf-doc:"Minimum TLS version. Default: TLSv1.2."`
HostPrivateKeyFiles []string `sconf:"optional" sconf-doc:"Private keys used for ACME certificates. Specified explicitly so DANE TLSA DNS records can be generated, even before the certificates are requested. DANE is a mechanism to authenticate remote TLS certificates based on a public key or certificate specified in DNS, protected with DNSSEC. DANE is opportunistic and attempted when delivering SMTP with STARTTLS. The private key files must be in PEM format. PKCS8 is recommended, but PKCS1 and EC private keys are recognized as well. Only RSA 2048 bit and ECDSA P-256 keys are currently used. The first of each is used when requesting new certificates through ACME."`
ClientAuthDisabled bool `sconf:"optional" sconf-doc:"Disable TLS client authentication with certificates/keys, preventing the TLS server from requesting a TLS certificate from clients. Useful for working around clients that don't handle TLS client authentication well."`
Config *tls.Config `sconf:"-" json:"-"` // TLS config for non-ACME-verification connections, i.e. SMTP and IMAP, and not port 443.
Config *tls.Config `sconf:"-" json:"-"` // TLS config for non-ACME-verification connections, i.e. SMTP and IMAP, and not port 443. Connections without SNI will use a certificate for the hostname of the listener, connections with an SNI hostname that isn't allowed will be rejected.
ConfigFallback *tls.Config `sconf:"-" json:"-"` // Like Config, but uses the certificate for the listener hostname when the requested SNI hostname is not allowed, instead of causing the connection to fail.
ACMEConfig *tls.Config `sconf:"-" json:"-"` // TLS config that handles ACME verification, for serving on port 443.
HostPrivateRSA2048Keys []crypto.Signer `sconf:"-" json:"-"` // Private keys for new TLS certificates for listener host name, for new certificates with ACME, and for DANE records.
HostPrivateECDSAP256Keys []crypto.Signer `sconf:"-" json:"-"`
}
// todo: we could implement matching WebHandler.Domain as IPs too
type WebHandler struct {
LogName string `sconf:"optional" sconf-doc:"Name to use in logging and metrics."`
Domain string `sconf-doc:"Both Domain and PathRegexp must match for this WebHandler to match a request. Exactly one of WebStatic, WebRedirect, WebForward must be set."`
Domain string `sconf-doc:"Both Domain and PathRegexp must match for this WebHandler to match a request. Exactly one of WebStatic, WebRedirect, WebForward, WebInternal must be set."`
PathRegexp string `sconf-doc:"Regular expression matched against request path, must always start with ^ to ensure matching from the start of the path. The matching prefix can optionally be stripped by WebForward. The regular expression does not have to end with $."`
DontRedirectPlainHTTP bool `sconf:"optional" sconf-doc:"If set, plain HTTP requests are not automatically permanently redirected (308) to HTTPS. If you don't have a HTTPS webserver configured, set this to true."`
Compress bool `sconf:"optional" sconf-doc:"Transparently compress responses (currently with gzip) if the client supports it, the status is 200 OK, no Content-Encoding is set on the response yet and the Content-Type of the response hints that the data is compressible (text/..., specific application/... and .../...+json and .../...+xml). For static files only, a cache with compressed files is kept."`
WebStatic *WebStatic `sconf:"optional" sconf-doc:"Serve static files."`
WebRedirect *WebRedirect `sconf:"optional" sconf-doc:"Redirect requests to configured URL."`
WebForward *WebForward `sconf:"optional" sconf-doc:"Forward requests to another webserver, i.e. reverse proxy."`
WebInternal *WebInternal `sconf:"optional" sconf-doc:"Pass request to internal service, like webmail, webapi, etc."`
Name string `sconf:"-"` // Either LogName, or numeric index if LogName was empty. Used instead of LogName in logging/metrics.
DNSDomain dns.Domain `sconf:"-"`
@ -468,6 +580,7 @@ func (wh WebHandler) Equal(o WebHandler) bool {
x.WebStatic = nil
x.WebRedirect = nil
x.WebForward = nil
x.WebInternal = nil
return x
}
cwh := clean(wh)
@ -475,7 +588,7 @@ func (wh WebHandler) Equal(o WebHandler) bool {
if cwh != co {
return false
}
if (wh.WebStatic == nil) != (o.WebStatic == nil) || (wh.WebRedirect == nil) != (o.WebRedirect == nil) || (wh.WebForward == nil) != (o.WebForward == nil) {
if (wh.WebStatic == nil) != (o.WebStatic == nil) || (wh.WebRedirect == nil) != (o.WebRedirect == nil) || (wh.WebForward == nil) != (o.WebForward == nil) || (wh.WebInternal == nil) != (o.WebInternal == nil) {
return false
}
if wh.WebStatic != nil {
@ -487,6 +600,9 @@ func (wh WebHandler) Equal(o WebHandler) bool {
if wh.WebForward != nil {
return wh.WebForward.equal(*o.WebForward)
}
if wh.WebInternal != nil {
return wh.WebInternal.equal(*o.WebInternal)
}
return true
}
@ -529,3 +645,16 @@ func (wf WebForward) equal(o WebForward) bool {
o.TargetURL = nil
return reflect.DeepEqual(wf, o)
}
type WebInternal struct {
BasePath string `sconf-doc:"Path to use as root of internal service, e.g. /webmail/."`
Service string `sconf-doc:"Name of the service, values: admin, account, webmail, webapi."`
Handler http.Handler `sconf:"-" json:"-"`
}
func (wi WebInternal) equal(o WebInternal) bool {
wi.Handler = nil
o.Handler = nil
return reflect.DeepEqual(wi, o)
}

View File

@ -1,15 +1,36 @@
/*
Package config holds the configuration file definitions for mox.conf (Static)
and domains.conf (Dynamic).
Package config holds the configuration file definitions.
These config files are in "sconf" format. Summarized: Indent with tabs, "#" as
first non-whitespace character makes the line a comment (you cannot have a line
with both a value and a comment), strings are not quoted/escaped and can never
span multiple lines. See https://pkg.go.dev/github.com/mjl-/sconf for details.
Mox uses two config files:
Annotated empty/default configuration files you could use as a starting point
for your mox.conf and domains.conf, as generated by "mox config
describe-static" and "mox config describe-domains":
1. mox.conf, also called the static configuration file.
2. domains.conf, also called the dynamic configuration file.
The static configuration file is never reloaded during the lifetime of a
running mox instance. After changes to mox.conf, mox must be restarted for the
changes to take effect.
The dynamic configuration file is reloaded automatically when it changes.
If the file contains an error after the change, the reload is aborted and the
previous version remains active.
Below are "empty" config files, generated from the config file definitions in
the source code, along with comments explaining the fields. Fields named "x" are
placeholders for user-chosen map keys.
# sconf
The config files are in "sconf" format. Properties of sconf files:
- Indentation with tabs only.
- "#" as first non-whitespace character makes the line a comment. Lines with a
value cannot also have a comment.
- Values don't have syntax indicating their type. For example, strings are
not quoted/escaped and can never span multiple lines.
- Fields that are optional can be left out completely. But the value of an
optional field may itself have required fields.
See https://pkg.go.dev/github.com/mjl-/sconf for details.
# mox.conf
@ -92,8 +113,11 @@ describe-static" and "mox config describe-domains":
# TLS port for ACME validation, 443 by default. You should only override this if
# you cannot listen on port 443 directly. ACME will make requests to port 443, so
# you'll have to add an external mechanism to get the connection here, e.g. by
# configuring port forwarding. (optional)
# you'll have to add an external mechanism to get the tls connection here, e.g. by
# configuring firewall-level port forwarding. Validation over the https port uses
# tls-alpn-01 with application-layer protocol negotiation, which essentially means
# the original tls connection must make it here unmodified, an https reverse proxy
# will not work. (optional)
Port: 0
# If set, used for suggested CAA DNS records, for restricting TLS certificate
@ -132,7 +156,11 @@ describe-static" and "mox config describe-domains":
# Use 0.0.0.0 to listen on all IPv4 and/or :: to listen on all IPv6 addresses, but
# it is better to explicitly specify the IPs you want to use for email, as mox
# will make sure outgoing connections will only be made from one of those IPs.
# will make sure outgoing connections will only be made from one of those IPs. If
# both outgoing IPv4 and IPv6 connectivity is possible, and only one family has
# explicitly configured addresses, both address families are still used for
# outgoing connections. Use the "direct" transport to limit address families for
# outgoing connections.
IPs:
-
@ -147,7 +175,10 @@ describe-static" and "mox config describe-domains":
# NATed. Skips IP-related DNS self-checks. (optional)
IPsNATed: false
# If empty, the config global Hostname is used. (optional)
# If empty, the config global Hostname is used. The internal services webadmin,
# webaccount, webmail and webapi only match requests to IPs, this hostname,
# "localhost". All except webadmin also match for any client settings domain.
# (optional)
Hostname:
# For SMTP/IMAP STARTTLS, direct TLS and HTTPS connections. (optional)
@ -186,6 +217,11 @@ describe-static" and "mox config describe-domains":
HostPrivateKeyFiles:
-
# Disable TLS client authentication with certificates/keys, preventing the TLS
# server from requesting a TLS certificate from clients. Useful for working around
# clients that don't handle TLS client authentication well. (optional)
ClientAuthDisabled: false
# Maximum size in bytes for incoming and outgoing messages. Default is 100MB.
# (optional)
SMTPMaxMessageSize: 0
@ -218,9 +254,12 @@ describe-static" and "mox config describe-domains":
# accept/reject decision. This prevents sending IPs of all communications to the
# block list provider. If any of the listed DNSBLs contains a requested IP
# address, the message is rejected as spam. The DNSBLs are checked for healthiness
# before use, at most once per 4 hours. Example DNSBLs: sbl.spamhaus.org,
# bl.spamcop.net. See https://www.spamhaus.org/sbl/ and https://www.spamcop.net/
# for more information and terms of use. (optional)
# before use, at most once per 4 hours. IPs we can send from are periodically
# checked for being in the configured DNSBLs. See MonitorDNSBLs in domains.conf to
# only monitor IPs we send from, without using those DNSBLs for incoming messages.
# Example DNSBLs: sbl.spamhaus.org, bl.spamcop.net. See
# https://www.spamhaus.org/sbl/ and https://www.spamcop.net/ for more information
# and terms of use. (optional)
DNSBLs:
-
@ -228,6 +267,10 @@ describe-static" and "mox config describe-domains":
# account. Default: 15s. (optional)
FirstTimeSenderDelay: 0s
# Override default setting for enabling TLS session tickets. Disabling session
# tickets may work around TLS interoperability issues. (optional)
TLSSessionTicketsDisabled: false
# SMTP for submitting email, e.g. by email applications. Starts out in plain text,
# can be upgraded to TLS with the STARTTLS command. Prefer using Submissions which
# is always a TLS connection. (optional)
@ -249,6 +292,14 @@ describe-static" and "mox config describe-domains":
# Default 465. (optional)
Port: 0
# Additionally enable submission on HTTPS port 443 via TLS ALPN. TLS Application
# Layer Protocol Negotiation allows clients to request a specific protocol from
# the server as part of the TLS connection setup. When this setting is enabled and
# a client requests the 'smtp' protocol after TLS, it will be able to talk SMTP to
# Mox on port 443. This is meant to be useful as a censorship circumvention
# technique for Delta Chat. (optional)
EnabledOnHTTPS: false
# IMAP for reading email, by email applications. Starts out in plain text, can be
# upgraded to TLS with the STARTTLS command. Prefer using IMAPS instead which is
# always a TLS connection. (optional)
@ -270,15 +321,25 @@ describe-static" and "mox config describe-domains":
# Default 993. (optional)
Port: 0
# Additionally enable IMAP on HTTPS port 443 via TLS ALPN. TLS Application Layer
# Protocol Negotiation allows clients to request a specific protocol from the
# server as part of the TLS connection setup. When this setting is enabled and a
# client requests the 'imap' protocol after TLS, it will be able to talk IMAP to
# Mox on port 443. This is meant to be useful as a censorship circumvention
# technique for Delta Chat. (optional)
EnabledOnHTTPS: false
# Account web interface, for email users wanting to change their accounts, e.g.
# set new password, set new delivery rulesets. Default path is /. (optional)
AccountHTTP:
Enabled: false
# Default 80 for HTTP and 443 for HTTPS. (optional)
# Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname
# matching behaviour. (optional)
Port: 0
# Path to serve requests on. (optional)
# Path to serve requests on. Should end with a slash, related to cookie paths.
# (optional)
Path:
# If set, X-Forwarded-* headers are used for the remote IP address for rate
@ -290,10 +351,12 @@ describe-static" and "mox config describe-domains":
AccountHTTPS:
Enabled: false
# Default 80 for HTTP and 443 for HTTPS. (optional)
# Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname
# matching behaviour. (optional)
Port: 0
# Path to serve requests on. (optional)
# Path to serve requests on. Should end with a slash, related to cookie paths.
# (optional)
Path:
# If set, X-Forwarded-* headers are used for the remote IP address for rate
@ -308,10 +371,12 @@ describe-static" and "mox config describe-domains":
AdminHTTP:
Enabled: false
# Default 80 for HTTP and 443 for HTTPS. (optional)
# Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname
# matching behaviour. (optional)
Port: 0
# Path to serve requests on. (optional)
# Path to serve requests on. Should end with a slash, related to cookie paths.
# (optional)
Path:
# If set, X-Forwarded-* headers are used for the remote IP address for rate
@ -323,10 +388,12 @@ describe-static" and "mox config describe-domains":
AdminHTTPS:
Enabled: false
# Default 80 for HTTP and 443 for HTTPS. (optional)
# Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname
# matching behaviour. (optional)
Port: 0
# Path to serve requests on. (optional)
# Path to serve requests on. Should end with a slash, related to cookie paths.
# (optional)
Path:
# If set, X-Forwarded-* headers are used for the remote IP address for rate
@ -337,10 +404,12 @@ describe-static" and "mox config describe-domains":
WebmailHTTP:
Enabled: false
# Default 80 for HTTP and 443 for HTTPS. (optional)
# Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname
# matching behaviour. (optional)
Port: 0
# Path to serve requests on. (optional)
# Path to serve requests on. Should end with a slash, related to cookie paths.
# (optional)
Path:
# If set, X-Forwarded-* headers are used for the remote IP address for rate
@ -352,10 +421,45 @@ describe-static" and "mox config describe-domains":
WebmailHTTPS:
Enabled: false
# Default 80 for HTTP and 443 for HTTPS. (optional)
# Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname
# matching behaviour. (optional)
Port: 0
# Path to serve requests on. (optional)
# Path to serve requests on. Should end with a slash, related to cookie paths.
# (optional)
Path:
# If set, X-Forwarded-* headers are used for the remote IP address for rate
# limiting and for the "secure" status of cookies. (optional)
Forwarded: false
# Like WebAPIHTTP, but with plain HTTP, without TLS. (optional)
WebAPIHTTP:
Enabled: false
# Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname
# matching behaviour. (optional)
Port: 0
# Path to serve requests on. Should end with a slash, related to cookie paths.
# (optional)
Path:
# If set, X-Forwarded-* headers are used for the remote IP address for rate
# limiting and for the "secure" status of cookies. (optional)
Forwarded: false
# WebAPI, a simple HTTP/JSON-based API for email, with HTTPS (requires a TLS
# config). Default path is /webapi/. (optional)
WebAPIHTTPS:
Enabled: false
# Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname
# matching behaviour. (optional)
Port: 0
# Path to serve requests on. Should end with a slash, related to cookie paths.
# (optional)
Path:
# If set, X-Forwarded-* headers are used for the remote IP address for rate
@ -415,6 +519,9 @@ describe-static" and "mox config describe-domains":
# Port for plain HTTP (non-TLS) webserver. (optional)
Port: 0
# Disable rate limiting for all requests to this port. (optional)
RateLimitDisabled: false
# All configured WebHandlers will serve on an enabled listener. Either ACME must
# be configured, or for each WebHandler domain a TLS certificate must be
# configured. (optional)
@ -424,6 +531,9 @@ describe-static" and "mox config describe-domains":
# Port for HTTPS webserver. (optional)
Port: 0
# Disable rate limiting for all requests to this port. (optional)
RateLimitDisabled: false
# Destination for emails delivered to postmaster addresses: a plain 'postmaster'
# without domain, 'postmaster@<hostname>' (also for each listener with SMTP
# enabled), and as fallback for each domain without explicitly configured
@ -447,13 +557,13 @@ describe-static" and "mox config describe-domains":
# Mailbox to deliver TLS reports to. Recommended value: TLSRPT.
Mailbox:
# Localpart at hostname to accept TLS reports at. Recommended value: tls-reports.
# Localpart at hostname to accept TLS reports at. Recommended value: tlsreports.
Localpart:
# Mailboxes to create for new accounts. Inbox is always created. Mailboxes can be
# given a 'special-use' role, which are understood by most mail clients. If
# absent/empty, the following mailboxes are created: Sent, Archive, Trash, Drafts
# and Junk. (optional)
# absent/empty, the following additional mailboxes are created: Sent, Archive,
# Trash, Drafts and Junk. (optional)
InitialMailboxes:
# Special-use roles to mailbox to create. (optional)
@ -614,6 +724,28 @@ describe-static" and "mox config describe-domains":
# typically the hostname of the host in the Address field.
RemoteHostname:
# Like regular direct delivery, but allows to tweak outgoing connections.
# (optional)
Direct:
# If set, outgoing SMTP connections will *NOT* use IPv4 addresses to connect to
# remote SMTP servers. (optional)
DisableIPv4: false
# If set, outgoing SMTP connections will *NOT* use IPv6 addresses to connect to
# remote SMTP servers. (optional)
DisableIPv6: false
# Immediately fails the delivery attempt. (optional)
Fail:
# SMTP error code and optional enhanced error code to use for the failure. If
# empty, 554 is used (transaction failed). (optional)
SMTPCode: 0
# Message to include for the rejection. It will be shown in the DSN. (optional)
SMTPMessage:
# Do not send DMARC reports (aggregate only). By default, aggregate reports on
# DMARC evaluations are sent to domains if their DMARC policy requests them.
# Reports are sent at whole hours, with a minimum of 1 hour and maximum of 24
@ -636,12 +768,13 @@ describe-static" and "mox config describe-domains":
# (optional)
OutgoingTLSReportsForAllSuccess: false
# Default maximum total message size for accounts, only applicable if greater than
# zero. Can be overridden per account. Attempting to add new messages beyond the
# maximum size will result in an error. Useful to prevent a single account from
# filling storage. The quota only applies to the email message files, not to any
# file system overhead and also not the message index database file (account for
# approximately 15% overhead). (optional)
# Default maximum total message size in bytes for each individual account, only
# applicable if greater than zero. Can be overridden per account. Attempting to
# add new messages to an account beyond its maximum total size will result in an
# error. Useful to prevent a single account from filling storage. The quota only
# applies to the email message files, not to any file system overhead and also not
# the message index database file (account for approximately 15% overhead).
# (optional)
QuotaMessageSize: 0
# domains.conf
@ -656,6 +789,19 @@ describe-static" and "mox config describe-domains":
Domains:
x:
# Disabled domains can be useful during/before migrations. Domains that are
# disabled can still be configured like normal, including adding addresses using
# the domain to accounts. However, disabled domains: 1. Do not try to fetch ACME
# certificates. TLS connections to host names involving the email domain will
# fail. A TLS certificate for the hostname (that wil be used as MX) itself will be
# requested. 2. Incoming deliveries over SMTP are rejected with a temporary error
# '450 4.2.1 recipient domain temporarily disabled'. 3. Submissions over SMTP
# using an (envelope) SMTP MAIL FROM address or message 'From' address of a
# disabled domain will be rejected with a temporary error '451 4.3.0 sender domain
# temporarily disabled'. Note that accounts with addresses at disabled domains can
# still log in and read email (unless the account itself is disabled). (optional)
Disabled: false
# Free-form description of domain. (optional)
Description:
@ -664,7 +810,7 @@ describe-static" and "mox config describe-domains":
# all clients to update their settings, it is convenient to have client settings
# that reference a subdomain of the hosted domain instead of the hostname of the
# server where the mail is currently hosted. If empty, the hostname of the mail
# server is used for client configurations. (optional)
# server is used for client configurations. Unicode name. (optional)
ClientSettingsDomain:
# If not empty, only the string before the separator is used to for email delivery
@ -672,6 +818,14 @@ describe-static" and "mox config describe-domains":
# delivered to you@example.com. (optional)
LocalpartCatchallSeparator:
# Similar to LocalpartCatchallSeparator, but in case multiple are needed. For
# example both "+" and "-". Only of one LocalpartCatchallSeparator or
# LocalpartCatchallSeparators can be set. If set, the first separator is used to
# make unique addresses for outgoing SMTP connections with FromIDLoginAddresses.
# (optional)
LocalpartCatchallSeparators:
-
# If set, upper/lower case is relevant for email delivery. (optional)
LocalpartCaseSensitive: false
@ -686,7 +840,7 @@ describe-static" and "mox config describe-domains":
Selectors:
x:
# sha256 (default) or (older, not recommended) sha1 (optional)
# sha256 (default) or (older, not recommended) sha1. (optional)
Hash:
# (optional)
@ -731,11 +885,18 @@ describe-static" and "mox config describe-domains":
DMARC:
# Address-part before the @ that accepts DMARC reports. Must be
# non-internationalized. Recommended value: dmarc-reports.
# non-internationalized. Recommended value: dmarcreports.
Localpart:
# Alternative domain for report recipient address. Can be used to receive reports
# for other domains. Unicode name. (optional)
# Alternative domain for reporting address, for incoming reports. Typically empty,
# causing the domain wherein this config exists to be used. Can be used to receive
# reports for domains that aren't fully hosted on this server. Configure such a
# domain as a hosted domain without making all the DNS changes, and configure this
# field with a domain that is fully hosted on this server, so the localpart and
# the domain of this field form a reporting address. Then only update the DMARC
# DNS record for the not fully hosted domain, ensuring the reporting address is
# specified in its "rua" field as shown in the suggested DNS settings. Unicode
# name. (optional)
Domain:
# Account to deliver to.
@ -744,17 +905,35 @@ describe-static" and "mox config describe-domains":
# Mailbox to deliver to, e.g. DMARC.
Mailbox:
# With MTA-STS a domain publishes, in DNS, presence of a policy for
# using/requiring TLS for SMTP connections. The policy is served over HTTPS.
# (optional)
# MTA-STS is a mechanism that allows publishing a policy with requirements for
# WebPKI-verified SMTP STARTTLS connections for email delivered to a domain.
# Existence of a policy is announced in a DNS TXT record (often
# unprotected/unverified, MTA-STS's weak spot). If a policy exists, it is fetched
# with a WebPKI-verified HTTPS request. The policy can indicate that
# WebPKI-verified SMTP STARTTLS is required, and which MX hosts (optionally with a
# wildcard pattern) are allowd. MX hosts to deliver to are still taken from DNS
# (again, not necessarily protected/verified), but messages will only be delivered
# to domains matching the MX hosts from the published policy. Mail servers look up
# the MTA-STS policy when first delivering to a domain, then keep a cached copy,
# periodically checking the DNS record if a new policy is available, and fetching
# and caching it if so. To update a policy, first serve a new policy with an
# updated policy ID, then update the DNS record (not the other way around). To
# remove an enforced policy, publish an updated policy with mode "none" for a long
# enough period so all cached policies have been refreshed (taking DNS TTL and
# policy max age into account), then remove the policy from DNS, wait for TTL to
# expire, and stop serving the policy. (optional)
MTASTS:
# Policies are versioned. The version must be specified in the DNS record. If you
# change a policy, first change it in mox, then update the DNS record.
# change a policy, first change it here to update the served policy, then update
# the DNS record with the updated policy ID.
PolicyID:
# testing, enforce or none. If set to enforce, a remote SMTP server will not
# deliver email to us if it cannot make a TLS connection.
# If set to "enforce", a remote SMTP server will not deliver email to us if it
# cannot make a WebPKI-verified SMTP STARTTLS connection. In mode "testing",
# deliveries can be done without verified TLS, but errors will be reported through
# TLS reporting. In mode "none", verified TLS is not required, used for phasing
# out an MTA-STS policy.
Mode:
# How long a remote mail server is allowed to cache a policy. Typically 1 or
@ -774,11 +953,18 @@ describe-static" and "mox config describe-domains":
TLSRPT:
# Address-part before the @ that accepts TLSRPT reports. Recommended value:
# tls-reports.
# tlsreports.
Localpart:
# Alternative domain for report recipient address. Can be used to receive reports
# for other domains. Unicode name. (optional)
# Alternative domain for reporting address, for incoming reports. Typically empty,
# causing the domain wherein this config exists to be used. Can be used to receive
# reports for domains that aren't fully hosted on this server. Configure such a
# domain as a hosted domain without making all the DNS changes, and configure this
# field with a domain that is fully hosted on this server, so the localpart and
# the domain of this field form a reporting address. Then only update the TLSRPT
# DNS record for the not fully hosted domain, ensuring the reporting address is
# specified in its "rua" field as shown in the suggested DNS settings. Unicode
# name. (optional)
Domain:
# Account to deliver to.
@ -811,11 +997,93 @@ describe-static" and "mox config describe-domains":
MinimumAttempts: 0
Transport:
# Accounts to which email can be delivered. An account can accept email for
# multiple domains, for multiple localparts, and deliver to multiple mailboxes.
# Aliases that cause messages to be delivered to one or more locally configured
# addresses. Keys are localparts (encoded, as they appear in email addresses).
# (optional)
Aliases:
x:
# Expanded addresses to deliver to. These must currently be of addresses of local
# accounts. To prevent duplicate messages, a member address that is also an
# explicit recipient in the SMTP transaction will only have the message delivered
# once. If the address in the message From header is a member, that member also
# won't receive the message.
Addresses:
-
# If true, anyone can send messages to the list. Otherwise only members, based on
# message From address, which is assumed to be DMARC-like-verified. (optional)
PostPublic: false
# If true, members can see addresses of members. (optional)
ListMembers: false
# If true, members are allowed to send messages with this alias address in the
# message From header. (optional)
AllowMsgFrom: false
# Accounts represent mox users, each with a password and email address(es) to
# which email can be delivered (possibly at different domains). Each account has
# its own on-disk directory holding its messages and index database. An account
# name is not an email address.
Accounts:
x:
# Webhooks for events about outgoing deliveries. (optional)
OutgoingWebhook:
# URL to POST webhooks.
URL:
# If not empty, value of Authorization header to add to HTTP requests. (optional)
Authorization:
# Events to send outgoing delivery notifications for. If absent, all events are
# sent. Valid values: delivered, suppressed, delayed, failed, relayed, expanded,
# canceled, unrecognized. (optional)
Events:
-
# Webhooks for events about incoming deliveries over SMTP. (optional)
IncomingWebhook:
# URL to POST webhooks to for incoming deliveries over SMTP.
URL:
# If not empty, value of Authorization header to add to HTTP requests. (optional)
Authorization:
# Login addresses that cause outgoing email to be sent with SMTP MAIL FROM
# addresses with a unique id after the localpart catchall separator (which must be
# enabled when addresses are specified here). Any delivery status notifications
# (DSN, e.g. for bounces), can be related to the original message and recipient
# with unique id's. You can login to an account with any valid email address,
# including variants with the localpart catchall separator. You can use this
# mechanism to both send outgoing messages with and without unique fromid for a
# given email address. With the webapi and webmail, a unique id will be generated.
# For submission, the id from the SMTP MAIL FROM command is used if present, and a
# unique id is generated otherwise. (optional)
FromIDLoginAddresses:
-
# Period to keep messages retired from the queue (delivered or failed) around.
# Keeping retired messages is useful for maintaining the suppression list for
# transactional email, for matching incoming DSNs to sent messages, and for
# debugging. The time at which to clean up (remove) is calculated at retire time.
# E.g. 168h (1 week). (optional)
KeepRetiredMessagePeriod: 0s
# Period to keep webhooks retired from the queue (delivered or failed) around.
# Useful for debugging. The time at which to clean up (remove) is calculated at
# retire time. E.g. 168h (1 week). (optional)
KeepRetiredWebhookPeriod: 0s
# If non-empty, login attempts on all protocols (e.g. SMTP/IMAP, web interfaces)
# is rejected with this error message. Useful during migrations. Incoming
# deliveries for addresses of this account are still accepted as normal.
# (optional)
LoginDisabled:
# Default domain for account. Deprecated behaviour: If a destination is not a full
# address but only a localpart, this domain is added to form a full address.
Domain:
@ -827,11 +1095,13 @@ describe-static" and "mox config describe-domains":
# be overridden per destination. (optional)
FullName:
# Destinations, keys are email addresses (with IDNA domains). If the address is of
# the form '@domain', i.e. with localpart missing, it serves as a catchall for the
# domain, matching all messages that are not explicitly configured. Deprecated
# behaviour: If the address is not a full address but a localpart, it is combined
# with Domain to form a full address.
# Destinations, keys are email addresses (with IDNA domains). All destinations are
# allowed for logging in with IMAP/SMTP/webmail. If no destinations are
# configured, the account can not login. If the address is of the form '@domain',
# i.e. with localpart missing, it serves as a catchall for the domain, matching
# all messages that are not explicitly configured. Deprecated behaviour: If the
# address is not a full address but a localpart, it is combined with Domain to
# form a full address. (optional)
Destinations:
x:
@ -849,6 +1119,10 @@ describe-static" and "mox config describe-domains":
# address (not the message From-header). E.g. '^user@example\.org$'. (optional)
SMTPMailFromRegexp:
# Matches if this regular expression matches (a substring of) the single address
# in the message From header. (optional)
MsgFromRegexp:
# Matches if this domain matches an SPF- and/or DKIM-verified (sub)domain.
# (optional)
VerifiedDomain:
@ -867,7 +1141,7 @@ describe-static" and "mox config describe-domains":
# matches this ruleset. Can only be used together with SMTPMailFromRegexp and
# VerifiedDomain. SMTPMailFromRegexp must be set to the address used to deliver
# the forwarded message, e.g. '^user(|\+.*)@forward\.example$'. Changes to junk
# analysis: 1. Messages are not rejects for failing a DMARC policy, because a
# analysis: 1. Messages are not rejected for failing a DMARC policy, because a
# legitimate forwarded message without valid/intact/aligned DKIM signature would
# be rejected because any verified SPF domain will be 'unaligned', of the
# forwarding mail server. 2. The sending mail server IP address, and sending EHLO
@ -899,6 +1173,31 @@ describe-static" and "mox config describe-domains":
# Mailbox to deliver to if this ruleset matches.
Mailbox:
# Free-form comments. (optional)
Comment:
# If non-empty, incoming delivery attempts to this destination will be rejected
# during SMTP RCPT TO with this error response line. Useful when a catchall
# address is configured for the domain and messages to some addresses should be
# rejected. The response line must start with an error code. Currently the
# following error resonse codes are allowed: 421 (temporary local error), 550
# (user not found). If the line consists of only an error code, an appropriate
# error message is added. Rejecting messages with a 4xx code invites later retries
# by the remote, while 5xx codes should prevent further delivery attempts.
# (optional)
SMTPError:
# If non-empty, an additional DMARC-like message authentication check is done for
# incoming messages, validating the domain in the From-header of the message.
# Messages without either an aligned SPF or aligned DKIM pass are rejected during
# the SMTP DATA command with a permanent error code followed by the message in
# this field. The domain in the message 'From' header is matched in relaxed or
# strict mode according to the domain's DMARC policy if present, or relaxed mode
# (organizational instead of exact domain match) otherwise. Useful for
# autoresponders that don't want to accept messages they don't want to send an
# automated reply to. (optional)
MessageAuthRequiredSMTPError:
# Full name to use in message From header when composing messages coming from this
# address with webmail. (optional)
FullName:
@ -913,11 +1212,11 @@ describe-static" and "mox config describe-domains":
# How long unique values are accepted after generating, e.g. 12h.
Period: 0s
# Default maximum total message size for the account, overriding any globally
# configured maximum size if non-zero. A negative value can be used to have no
# limit in case there is a limit by default. Attempting to add new messages beyond
# the maximum size will result in an error. Useful to prevent a single account
# from filling storage. (optional)
# Default maximum total message size in bytes for the account, overriding any
# globally configured default maximum size if non-zero. A negative value can be
# used to have no limit in case there is a limit by default. Attempting to add new
# messages to an account beyond its maximum total size will result in an error.
# Useful to prevent a single account from filling storage. (optional)
QuotaMessageSize: 0
# Mail that looks like spam will be rejected, but a copy can be stored temporarily
@ -929,7 +1228,8 @@ describe-static" and "mox config describe-domains":
RejectsMailbox:
# Don't automatically delete mail in the RejectsMailbox listed above. This can be
# useful, e.g. for future spam training. (optional)
# useful, e.g. for future spam training. It can also cause storage to fill up.
# (optional)
KeepRejects: false
# Automatically set $Junk and $NotJunk flags based on mailbox messages are
@ -938,11 +1238,11 @@ describe-static" and "mox config describe-domains":
# all move messages to a different mailbox, so this helps them. (optional)
AutomaticJunkFlags:
# If enabled, flags will be set automatically if they match a regular expression
# below. When two of the three mailbox regular expressions are set, the remaining
# one will match all unmatched messages. Messages are matched in the order
# specified and the search stops on the first match. Mailboxes are lowercased
# before matching.
# If enabled, junk/nonjunk flags will be set automatically if they match some of
# the regular expressions. When two of the three mailbox regular expressions are
# set, the remaining one will match all unmatched messages. Messages are matched
# in the order 'junk', 'neutral', 'not junk', and the search stops on the first
# match. Mailboxes are lowercased before matching.
Enabled: false
# Example: ^(junk|spam). (optional)
@ -1003,6 +1303,17 @@ describe-static" and "mox config describe-domains":
# this mail server in case of account compromise. Default 200. (optional)
MaxFirstTimeRecipientsPerDay: 0
# Do not apply a delay to SMTP connections before accepting an incoming message
# from a first-time sender. Can be useful for accounts that sends automated
# responses and want instant replies. (optional)
NoFirstTimeSenderDelay: false
# If set, this account cannot set a password of their own choice, but can only set
# a new randomly generated password, preventing password reuse across services and
# use of weak passwords. Custom account passwords can be set by the admin.
# (optional)
NoCustomPassword: false
# Routes for delivering outgoing messages through the queue. Each delivery attempt
# evaluates these account routes, domain routes and finally global routes. The
# transport of the first matching route is used in the delivery attempt. If no
@ -1032,12 +1343,15 @@ describe-static" and "mox config describe-domains":
WebDomainRedirects:
x:
# Handle webserver requests by serving static files, redirecting or
# reverse-proxying HTTP(s). The first matching WebHandler will handle the request.
# Built-in handlers, e.g. for account, admin, autoconfig and mta-sts always run
# first. If no handler matches, the response status code is file not found (404).
# If functionality you need is missng, simply forward the requests to an
# application that can provide the needed functionality. (optional)
# Handle webserver requests by serving static files, redirecting, reverse-proxying
# HTTP(s) or passing the request to an internal service. The first matching
# WebHandler will handle the request. Built-in system handlers, e.g. for ACME
# validation, autoconfig and mta-sts always run first. Built-in handlers for
# admin, account, webmail and webapi are evaluated after all handlers, including
# webhandlers (allowing for overrides of internal services for some domains). If
# no handler matches, the response status code is file not found (404). If
# webserver features are missing, forward the requests to an application that
# provides the needed functionality itself. (optional)
WebHandlers:
-
@ -1045,7 +1359,7 @@ describe-static" and "mox config describe-domains":
LogName:
# Both Domain and PathRegexp must match for this WebHandler to match a request.
# Exactly one of WebStatic, WebRedirect, WebForward must be set.
# Exactly one of WebStatic, WebRedirect, WebForward, WebInternal must be set.
Domain:
# Regular expression matched against request path, must always start with ^ to
@ -1152,6 +1466,15 @@ describe-static" and "mox config describe-domains":
ResponseHeaders:
x:
# Pass request to internal service, like webmail, webapi, etc. (optional)
WebInternal:
# Path to use as root of internal service, e.g. /webmail/.
BasePath:
# Name of the service, values: admin, account, webmail, webapi.
Service:
# Routes for delivering outgoing messages through the queue. Each delivery attempt
# evaluates account routes, domain routes and finally these global routes. The
# transport of the first matching route is used in the delivery attempt. If no
@ -1176,11 +1499,19 @@ describe-static" and "mox config describe-domains":
MinimumAttempts: 0
Transport:
# DNS blocklists to periodically check with if IPs we send from are present,
# without using them for checking incoming deliveries.. Also see DNSBLs in SMTP
# listeners in mox.conf, which specifies DNSBLs to use both for incoming
# deliveries and for checking our IPs against. Example DNSBLs: sbl.spamhaus.org,
# bl.spamcop.net. (optional)
MonitorDNSBLs:
-
# Examples
Mox includes configuration files to illustrate common setups. You can see these
examples with "mox example", and print a specific example with "mox example
<name>". Below are all examples included in mox.
examples with "mox config example", and print a specific example with "mox
config example <name>". Below are all examples included in mox.
# Example webhandlers
@ -1264,7 +1595,7 @@ examples with "mox example", and print a specific example with "mox example
# Example transport
# Snippet for mox.conf, defining a transport called Example that connects on the
# SMTP submission with TLS port 465 ("submissions), authenticating with
# SMTP submission with TLS port 465 ("submissions"), authenticating with
# SCRAM-SHA-256-PLUS (other providers may not support SCRAM-SHA-256-PLUS, but they
# typically do support the older CRAM-MD5).:

1336
ctl.go

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,31 @@
//go:build !quickstart && !integration
//go:build !integration
package main
import (
"context"
"crypto/ed25519"
cryptorand "crypto/rand"
"crypto/x509"
"flag"
"fmt"
"log/slog"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dmarcdb"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/imapclient"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/mtastsdb"
"github.com/mjl-/mox/queue"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/store"
"github.com/mjl-/mox/tlsrptdb"
)
@ -35,154 +45,422 @@ func tcheck(t *testing.T, err error, errmsg string) {
// unhandled errors would cause a panic.
func TestCtl(t *testing.T) {
os.RemoveAll("testdata/ctl/data")
mox.ConfigStaticPath = filepath.FromSlash("testdata/ctl/mox.conf")
mox.ConfigDynamicPath = filepath.FromSlash("testdata/ctl/domains.conf")
mox.ConfigStaticPath = filepath.FromSlash("testdata/ctl/config/mox.conf")
mox.ConfigDynamicPath = filepath.FromSlash("testdata/ctl/config/domains.conf")
if errs := mox.LoadConfig(ctxbg, pkglog, true, false); len(errs) > 0 {
t.Fatalf("loading mox config: %v", errs)
}
err := store.Init(ctxbg)
tcheck(t, err, "store init")
defer store.Close()
defer store.Switchboard()()
testctl := func(fn func(clientctl *ctl)) {
err = queue.Init()
tcheck(t, err, "queue init")
defer queue.Shutdown()
var cid int64
testctl := func(fn func(clientxctl *ctl)) {
t.Helper()
cconn, sconn := net.Pipe()
clientctl := ctl{conn: cconn, log: pkglog}
serverctl := ctl{conn: sconn, log: pkglog}
go servectlcmd(ctxbg, &serverctl, func() {})
fn(&clientctl)
clientxctl := ctl{conn: cconn, log: pkglog}
serverxctl := ctl{conn: sconn, log: pkglog}
done := make(chan struct{})
go func() {
cid++
servectlcmd(ctxbg, &serverxctl, cid, func() {})
close(done)
}()
fn(&clientxctl)
cconn.Close()
<-done
sconn.Close()
}
// "deliver"
testctl(func(ctl *ctl) {
ctlcmdDeliver(ctl, "mjl@mox.example")
testctl(func(xctl *ctl) {
ctlcmdDeliver(xctl, "mjl@mox.example")
})
// "setaccountpassword"
testctl(func(ctl *ctl) {
ctlcmdSetaccountpassword(ctl, "mjl", "test4321")
testctl(func(xctl *ctl) {
ctlcmdSetaccountpassword(xctl, "mjl", "test4321")
})
err := queue.Init()
tcheck(t, err, "queue init")
// "queue"
testctl(func(ctl *ctl) {
ctlcmdQueueList(ctl)
testctl(func(xctl *ctl) {
ctlcmdQueueHoldrulesList(xctl)
})
// "queuekick"
testctl(func(ctl *ctl) {
ctlcmdQueueKick(ctl, 0, "", "", "")
// All messages.
testctl(func(xctl *ctl) {
ctlcmdQueueHoldrulesAdd(xctl, "", "", "")
})
testctl(func(xctl *ctl) {
ctlcmdQueueHoldrulesAdd(xctl, "mjl", "", "")
})
testctl(func(xctl *ctl) {
ctlcmdQueueHoldrulesAdd(xctl, "", "☺.mox.example", "")
})
testctl(func(xctl *ctl) {
ctlcmdQueueHoldrulesAdd(xctl, "mox", "☺.mox.example", "example.com")
})
testctl(func(xctl *ctl) {
ctlcmdQueueHoldrulesRemove(xctl, 1)
})
// Queue a message to list/change/dump.
msg := "Subject: subject\r\n\r\nbody\r\n"
msgFile, err := store.CreateMessageTemp(pkglog, "queuedump-test")
tcheck(t, err, "temp file")
_, err = msgFile.Write([]byte(msg))
tcheck(t, err, "write message")
_, err = msgFile.Seek(0, 0)
tcheck(t, err, "rewind message")
defer os.Remove(msgFile.Name())
defer msgFile.Close()
addr, err := smtp.ParseAddress("mjl@mox.example")
tcheck(t, err, "parse address")
qml := []queue.Msg{queue.MakeMsg(addr.Path(), addr.Path(), false, false, int64(len(msg)), "<random@localhost>", nil, nil, time.Now(), "subject")}
queue.Add(ctxbg, pkglog, "mjl", msgFile, qml...)
qmid := qml[0].ID
// Has entries now.
testctl(func(xctl *ctl) {
ctlcmdQueueHoldrulesList(xctl)
})
// "queuelist"
testctl(func(xctl *ctl) {
ctlcmdQueueList(xctl, queue.Filter{}, queue.Sort{})
})
// "queueholdset"
testctl(func(xctl *ctl) {
ctlcmdQueueHoldSet(xctl, queue.Filter{}, true)
})
testctl(func(xctl *ctl) {
ctlcmdQueueHoldSet(xctl, queue.Filter{}, false)
})
// "queueschedule"
testctl(func(xctl *ctl) {
ctlcmdQueueSchedule(xctl, queue.Filter{}, true, time.Minute)
})
// "queuetransport"
testctl(func(xctl *ctl) {
ctlcmdQueueTransport(xctl, queue.Filter{}, "socks")
})
// "queuerequiretls"
testctl(func(xctl *ctl) {
ctlcmdQueueRequireTLS(xctl, queue.Filter{}, nil)
})
// "queuedump"
testctl(func(xctl *ctl) {
ctlcmdQueueDump(xctl, fmt.Sprintf("%d", qmid))
})
// "queuefail"
testctl(func(xctl *ctl) {
ctlcmdQueueFail(xctl, queue.Filter{})
})
// "queuedrop"
testctl(func(ctl *ctl) {
ctlcmdQueueDrop(ctl, 0, "", "")
testctl(func(xctl *ctl) {
ctlcmdQueueDrop(xctl, queue.Filter{})
})
// no "queuedump", we don't have a message to dump, and the commands exits without a message.
// "queueholdruleslist"
testctl(func(xctl *ctl) {
ctlcmdQueueHoldrulesList(xctl)
})
// "queueholdrulesadd"
testctl(func(xctl *ctl) {
ctlcmdQueueHoldrulesAdd(xctl, "mjl", "", "")
})
testctl(func(xctl *ctl) {
ctlcmdQueueHoldrulesAdd(xctl, "mjl", "localhost", "")
})
// "queueholdrulesremove"
testctl(func(xctl *ctl) {
ctlcmdQueueHoldrulesRemove(xctl, 2)
})
testctl(func(xctl *ctl) {
ctlcmdQueueHoldrulesList(xctl)
})
// "queuesuppresslist"
testctl(func(xctl *ctl) {
ctlcmdQueueSuppressList(xctl, "mjl")
})
// "queuesuppressadd"
testctl(func(xctl *ctl) {
ctlcmdQueueSuppressAdd(xctl, "mjl", "base@localhost")
})
testctl(func(xctl *ctl) {
ctlcmdQueueSuppressAdd(xctl, "mjl", "other@localhost")
})
// "queuesuppresslookup"
testctl(func(xctl *ctl) {
ctlcmdQueueSuppressLookup(xctl, "mjl", "base@localhost")
})
// "queuesuppressremove"
testctl(func(xctl *ctl) {
ctlcmdQueueSuppressRemove(xctl, "mjl", "base@localhost")
})
testctl(func(xctl *ctl) {
ctlcmdQueueSuppressList(xctl, "mjl")
})
// "queueretiredlist"
testctl(func(xctl *ctl) {
ctlcmdQueueRetiredList(xctl, queue.RetiredFilter{}, queue.RetiredSort{})
})
// "queueretiredprint"
testctl(func(xctl *ctl) {
ctlcmdQueueRetiredPrint(xctl, "1")
})
// "queuehooklist"
testctl(func(xctl *ctl) {
ctlcmdQueueHookList(xctl, queue.HookFilter{}, queue.HookSort{})
})
// "queuehookschedule"
testctl(func(xctl *ctl) {
ctlcmdQueueHookSchedule(xctl, queue.HookFilter{}, true, time.Minute)
})
// "queuehookprint"
testctl(func(xctl *ctl) {
ctlcmdQueueHookPrint(xctl, "1")
})
// "queuehookcancel"
testctl(func(xctl *ctl) {
ctlcmdQueueHookCancel(xctl, queue.HookFilter{})
})
// "queuehookretiredlist"
testctl(func(xctl *ctl) {
ctlcmdQueueHookRetiredList(xctl, queue.HookRetiredFilter{}, queue.HookRetiredSort{})
})
// "queuehookretiredprint"
testctl(func(xctl *ctl) {
ctlcmdQueueHookRetiredPrint(xctl, "1")
})
// "importmbox"
testctl(func(ctl *ctl) {
ctlcmdImport(ctl, true, "mjl", "inbox", "testdata/importtest.mbox")
testctl(func(xctl *ctl) {
ctlcmdImport(xctl, true, "mjl", "inbox", "testdata/importtest.mbox")
})
// "importmaildir"
testctl(func(ctl *ctl) {
ctlcmdImport(ctl, false, "mjl", "inbox", "testdata/importtest.maildir")
testctl(func(xctl *ctl) {
ctlcmdImport(xctl, false, "mjl", "inbox", "testdata/importtest.maildir")
})
// "domainadd"
testctl(func(ctl *ctl) {
ctlcmdConfigDomainAdd(ctl, dns.Domain{ASCII: "mox2.example"}, "mjl", "")
testctl(func(xctl *ctl) {
ctlcmdConfigDomainAdd(xctl, false, dns.Domain{ASCII: "mox2.example"}, "mjl", "")
})
// "accountadd"
testctl(func(ctl *ctl) {
ctlcmdConfigAccountAdd(ctl, "mjl2", "mjl2@mox2.example")
testctl(func(xctl *ctl) {
ctlcmdConfigAccountAdd(xctl, "mjl2", "mjl2@mox2.example")
})
// "addressadd"
testctl(func(ctl *ctl) {
ctlcmdConfigAddressAdd(ctl, "mjl3@mox2.example", "mjl2")
testctl(func(xctl *ctl) {
ctlcmdConfigAddressAdd(xctl, "mjl3@mox2.example", "mjl2")
})
// Add a message.
testctl(func(ctl *ctl) {
ctlcmdDeliver(ctl, "mjl3@mox2.example")
testctl(func(xctl *ctl) {
ctlcmdDeliver(xctl, "mjl3@mox2.example")
})
// "retrain", retrain junk filter.
testctl(func(ctl *ctl) {
ctlcmdRetrain(ctl, "mjl2")
testctl(func(xctl *ctl) {
ctlcmdRetrain(xctl, "mjl2")
})
// "addressrm"
testctl(func(ctl *ctl) {
ctlcmdConfigAddressRemove(ctl, "mjl3@mox2.example")
testctl(func(xctl *ctl) {
ctlcmdConfigAddressRemove(xctl, "mjl3@mox2.example")
})
// "accountdisabled"
testctl(func(xctl *ctl) {
ctlcmdConfigAccountDisabled(xctl, "mjl2", "testing")
})
// "accountlist"
testctl(func(xctl *ctl) {
ctlcmdConfigAccountList(xctl)
})
testctl(func(xctl *ctl) {
ctlcmdConfigAccountDisabled(xctl, "mjl2", "")
})
// "accountrm"
testctl(func(ctl *ctl) {
ctlcmdConfigAccountRemove(ctl, "mjl2")
testctl(func(xctl *ctl) {
ctlcmdConfigAccountRemove(xctl, "mjl2")
})
// "domaindisabled"
testctl(func(xctl *ctl) {
ctlcmdConfigDomainDisabled(xctl, dns.Domain{ASCII: "mox2.example"}, true)
})
testctl(func(xctl *ctl) {
ctlcmdConfigDomainDisabled(xctl, dns.Domain{ASCII: "mox2.example"}, false)
})
// "domainrm"
testctl(func(ctl *ctl) {
ctlcmdConfigDomainRemove(ctl, dns.Domain{ASCII: "mox2.example"})
testctl(func(xctl *ctl) {
ctlcmdConfigDomainRemove(xctl, dns.Domain{ASCII: "mox2.example"})
})
// "aliasadd"
testctl(func(xctl *ctl) {
ctlcmdConfigAliasAdd(xctl, "support@mox.example", config.Alias{Addresses: []string{"mjl@mox.example"}})
})
// "aliaslist"
testctl(func(xctl *ctl) {
ctlcmdConfigAliasList(xctl, "mox.example")
})
// "aliasprint"
testctl(func(xctl *ctl) {
ctlcmdConfigAliasPrint(xctl, "support@mox.example")
})
// "aliasupdate"
testctl(func(xctl *ctl) {
ctlcmdConfigAliasUpdate(xctl, "support@mox.example", "true", "true", "true")
})
// "aliasaddaddr"
testctl(func(xctl *ctl) {
ctlcmdConfigAliasAddaddr(xctl, "support@mox.example", []string{"mjl2@mox.example"})
})
// "aliasrmaddr"
testctl(func(xctl *ctl) {
ctlcmdConfigAliasRmaddr(xctl, "support@mox.example", []string{"mjl2@mox.example"})
})
// "aliasrm"
testctl(func(xctl *ctl) {
ctlcmdConfigAliasRemove(xctl, "support@mox.example")
})
// accounttlspubkeyadd
certDER := fakeCert(t)
testctl(func(xctl *ctl) {
ctlcmdConfigTlspubkeyAdd(xctl, "mjl@mox.example", "testkey", false, certDER)
})
// "accounttlspubkeylist"
testctl(func(xctl *ctl) {
ctlcmdConfigTlspubkeyList(xctl, "")
})
testctl(func(xctl *ctl) {
ctlcmdConfigTlspubkeyList(xctl, "mjl")
})
tpkl, err := store.TLSPublicKeyList(ctxbg, "")
tcheck(t, err, "list tls public keys")
if len(tpkl) != 1 {
t.Fatalf("got %d tls public keys, expected 1", len(tpkl))
}
fingerprint := tpkl[0].Fingerprint
// "accounttlspubkeyget"
testctl(func(xctl *ctl) {
ctlcmdConfigTlspubkeyGet(xctl, fingerprint)
})
// "accounttlspubkeyrm"
testctl(func(xctl *ctl) {
ctlcmdConfigTlspubkeyRemove(xctl, fingerprint)
})
tpkl, err = store.TLSPublicKeyList(ctxbg, "")
tcheck(t, err, "list tls public keys")
if len(tpkl) != 0 {
t.Fatalf("got %d tls public keys, expected 0", len(tpkl))
}
// "loglevels"
testctl(func(ctl *ctl) {
ctlcmdLoglevels(ctl)
testctl(func(xctl *ctl) {
ctlcmdLoglevels(xctl)
})
// "setloglevels"
testctl(func(ctl *ctl) {
ctlcmdSetLoglevels(ctl, "", "debug")
testctl(func(xctl *ctl) {
ctlcmdSetLoglevels(xctl, "", "debug")
})
testctl(func(ctl *ctl) {
ctlcmdSetLoglevels(ctl, "smtpserver", "debug")
testctl(func(xctl *ctl) {
ctlcmdSetLoglevels(xctl, "smtpserver", "debug")
})
// Export data, import it again
xcmdExport(true, []string{filepath.FromSlash("testdata/ctl/data/tmp/export/mbox/"), filepath.FromSlash("testdata/ctl/data/accounts/mjl")}, &cmd{log: pkglog})
xcmdExport(false, []string{filepath.FromSlash("testdata/ctl/data/tmp/export/maildir/"), filepath.FromSlash("testdata/ctl/data/accounts/mjl")}, &cmd{log: pkglog})
testctl(func(ctl *ctl) {
ctlcmdImport(ctl, true, "mjl", "inbox", filepath.FromSlash("testdata/ctl/data/tmp/export/mbox/Inbox.mbox"))
xcmdExport(true, false, []string{filepath.FromSlash("testdata/ctl/data/tmp/export/mbox/"), filepath.FromSlash("testdata/ctl/data/accounts/mjl")}, &cmd{log: pkglog})
xcmdExport(false, false, []string{filepath.FromSlash("testdata/ctl/data/tmp/export/maildir/"), filepath.FromSlash("testdata/ctl/data/accounts/mjl")}, &cmd{log: pkglog})
testctl(func(xctl *ctl) {
ctlcmdImport(xctl, true, "mjl", "inbox", filepath.FromSlash("testdata/ctl/data/tmp/export/mbox/Inbox.mbox"))
})
testctl(func(ctl *ctl) {
ctlcmdImport(ctl, false, "mjl", "inbox", filepath.FromSlash("testdata/ctl/data/tmp/export/maildir/Inbox"))
testctl(func(xctl *ctl) {
ctlcmdImport(xctl, false, "mjl", "inbox", filepath.FromSlash("testdata/ctl/data/tmp/export/maildir/Inbox"))
})
// "recalculatemailboxcounts"
testctl(func(ctl *ctl) {
ctlcmdRecalculateMailboxCounts(ctl, "mjl")
testctl(func(xctl *ctl) {
ctlcmdRecalculateMailboxCounts(xctl, "mjl")
})
// "fixmsgsize"
testctl(func(ctl *ctl) {
ctlcmdFixmsgsize(ctl, "mjl")
testctl(func(xctl *ctl) {
ctlcmdFixmsgsize(xctl, "mjl")
})
testctl(func(ctl *ctl) {
acc, err := store.OpenAccount(ctl.log, "mjl")
testctl(func(xctl *ctl) {
acc, err := store.OpenAccount(xctl.log, "mjl", false)
tcheck(t, err, "open account")
defer acc.Close()
defer func() {
acc.Close()
acc.WaitClosed()
}()
content := []byte("Subject: hi\r\n\r\nbody\r\n")
deliver := func(m *store.Message) {
t.Helper()
m.Size = int64(len(content))
msgf, err := store.CreateMessageTemp(ctl.log, "ctltest")
msgf, err := store.CreateMessageTemp(xctl.log, "ctltest")
tcheck(t, err, "create temp file")
defer os.Remove(msgf.Name())
defer msgf.Close()
_, err = msgf.Write(content)
tcheck(t, err, "write message file")
err = acc.DeliverMailbox(ctl.log, "Inbox", m, msgf)
acc.WithWLock(func() {
err = acc.DeliverMailbox(xctl.log, "Inbox", m, msgf)
tcheck(t, err, "deliver message")
})
}
var msgBadSize store.Message
@ -200,7 +478,7 @@ func TestCtl(t *testing.T) {
tcheck(t, err, "update mailbox size")
// Fix up the size.
ctlcmdFixmsgsize(ctl, "")
ctlcmdFixmsgsize(xctl, "")
err = acc.DB.Get(ctxbg, &msgBadSize)
tcheck(t, err, "get message")
@ -210,39 +488,71 @@ func TestCtl(t *testing.T) {
})
// "reparse"
testctl(func(ctl *ctl) {
ctlcmdReparse(ctl, "mjl")
testctl(func(xctl *ctl) {
ctlcmdReparse(xctl, "mjl")
})
testctl(func(ctl *ctl) {
ctlcmdReparse(ctl, "")
testctl(func(xctl *ctl) {
ctlcmdReparse(xctl, "")
})
// "reassignthreads"
testctl(func(ctl *ctl) {
ctlcmdReassignthreads(ctl, "mjl")
testctl(func(xctl *ctl) {
ctlcmdReassignthreads(xctl, "mjl")
})
testctl(func(ctl *ctl) {
ctlcmdReassignthreads(ctl, "")
testctl(func(xctl *ctl) {
ctlcmdReassignthreads(xctl, "")
})
// "backup", backup account.
err = dmarcdb.Init()
tcheck(t, err, "dmarcdb init")
defer dmarcdb.Close()
err = mtastsdb.Init(false)
tcheck(t, err, "mtastsdb init")
defer mtastsdb.Close()
err = tlsrptdb.Init()
tcheck(t, err, "tlsrptdb init")
testctl(func(ctl *ctl) {
os.RemoveAll("testdata/ctl/data/tmp/backup-data")
defer tlsrptdb.Close()
testctl(func(xctl *ctl) {
os.RemoveAll("testdata/ctl/data/tmp/backup")
err := os.WriteFile("testdata/ctl/data/receivedid.key", make([]byte, 16), 0600)
tcheck(t, err, "writing receivedid.key")
ctlcmdBackup(ctl, filepath.FromSlash("testdata/ctl/data/tmp/backup-data"), false)
ctlcmdBackup(xctl, filepath.FromSlash("testdata/ctl/data/tmp/backup"), false)
})
// Verify the backup.
xcmd := cmd{
flag: flag.NewFlagSet("", flag.ExitOnError),
flagArgs: []string{filepath.FromSlash("testdata/ctl/data/tmp/backup-data")},
flagArgs: []string{filepath.FromSlash("testdata/ctl/data/tmp/backup/data")},
}
cmdVerifydata(&xcmd)
// IMAP connection.
testctl(func(xctl *ctl) {
a, b := net.Pipe()
go func() {
opts := imapclient.Opts{
Logger: slog.Default().With("cid", mox.Cid()),
Error: func(err error) { panic(err) },
}
client, err := imapclient.New(a, &opts)
tcheck(t, err, "new imapclient")
client.Select("inbox")
client.Logout()
defer a.Close()
}()
ctlcmdIMAPServe(xctl, "mjl@mox.example", b, b)
})
}
func fakeCert(t *testing.T) []byte {
t.Helper()
seed := make([]byte, ed25519.SeedSize)
privKey := ed25519.NewKeyFromSeed(seed) // Fake key, don't use this for real!
template := &x509.Certificate{
SerialNumber: big.NewInt(1), // Required field...
}
localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
tcheck(t, err, "making certificate")
return localCertBuf
}

14
curves.go Normal file
View File

@ -0,0 +1,14 @@
//go:build !go1.24
package main
import (
"crypto/tls"
)
var curvesList = []tls.CurveID{
tls.CurveP256,
tls.CurveP384,
tls.CurveP521,
tls.X25519,
}

15
curves_go124.go Normal file
View File

@ -0,0 +1,15 @@
//go:build go1.24
package main
import (
"crypto/tls"
)
var curvesList = []tls.CurveID{
tls.CurveP256,
tls.CurveP384,
tls.CurveP521,
tls.X25519,
tls.X25519MLKEM768,
}

View File

@ -55,17 +55,17 @@ import (
"crypto/x509"
"errors"
"fmt"
"log/slog"
"net"
"strings"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/adns"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/stub"
"slices"
)
var (
@ -215,12 +215,9 @@ func Dial(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, network
if allowedUsages != nil {
o := 0
for _, r := range records {
for _, usage := range allowedUsages {
if r.Usage == usage {
if slices.Contains(allowedUsages, r.Usage) {
records[o] = r
o++
break
}
}
}
records = records[:o]
@ -264,7 +261,8 @@ func Dial(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, network
config := TLSClientConfig(log.Logger, records, baseDom, moreAllowedHosts, &verifiedRecord, pkixRoots)
tlsConn := tls.Client(conn, &config)
if err := tlsConn.HandshakeContext(ctx); err != nil {
conn.Close()
xerr := conn.Close()
log.Check(xerr, "closing connection")
return nil, adns.TLSA{}, err
}
return tlsConn, verifiedRecord, nil
@ -449,6 +447,7 @@ func verifySingle(log mlog.Log, tlsa adns.TLSA, cs tls.ConnectionState, allowedH
// We set roots, so the system defaults don't get used. Verify checks the host name
// (set below) and checks for expiration.
opts := x509.VerifyOptions{
Intermediates: x509.NewCertPool(),
Roots: x509.NewCertPool(),
}
@ -466,11 +465,13 @@ func verifySingle(log mlog.Log, tlsa adns.TLSA, cs tls.ConnectionState, allowedH
}
}
for _, cert := range cs.PeerCertificates {
for i, cert := range cs.PeerCertificates {
if match(cert) {
opts.Roots.AddCert(cert)
found = true
break
} else if i > 0 {
opts.Intermediates.AddCert(cert)
}
}
if !found {

View File

@ -20,8 +20,6 @@ import (
"testing"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/adns"
"github.com/mjl-/mox/dns"
@ -37,7 +35,6 @@ func tcheckf(t *testing.T, err error, format string, args ...any) {
// Test dialing and DANE TLS verification.
func TestDial(t *testing.T) {
mlog.SetConfig(map[string]slog.Level{"": mlog.LevelDebug})
log := mlog.New("dane", nil)
// Create fake CA/trusted-anchor certificate.

View File

@ -4,8 +4,7 @@ import (
"context"
"crypto/x509"
"log"
"golang.org/x/exp/slog"
"log/slog"
"github.com/mjl-/adns"

View File

@ -1,5 +1,34 @@
This file has notes useful for mox developers.
# Building & testing
For a full build, you'll need a recent Go compiler/toolchain and nodejs/npm for
the frontend. Run "make build" to do a full build. Run "make test" to run the
test suite. With docker installed, you can run "make test-integration" to start
up a few mox instances, a dns server, a postfix instance, and send email
between them.
The mox localserve command is a convenient way to test locally. Most of the
code paths are reachable/testable with mox localserve, but some use cases will
require a full setup.
Before committing, run at least "make fmt" and "make check" (which requires
staticcheck and ineffassign, run "make install-staticcheck install-ineffassign"
once). Also run "make check-shadow" and fix any shadowed variables other than
"err" (which are filtered out, but causes the command to always exit with an
error code; run "make install-shadow" once to install the shadow command). If
you've updated RFC references, run "make" in rfc/, it verifies the referenced
files exist.
When making changes to the public API of a package listed in
apidiff/packages.txt, run "make genapidiff" to update the list of changes in
the upcoming release (run "make install-apidiff" once to install the apidiff
command).
New features may be worth mentioning on the website, see website/ and
instructions below.
# Code style, guidelines, notes
- Keep the same style as existing code.
@ -18,6 +47,19 @@ This file has notes useful for mox developers.
standard slog package for logging, not our mlog package. Packages not intended
for reuse do use mlog as it is more convenient. Internally, we always use
mlog.Log to do the logging, wrapping an slog.Logger.
- The code uses panic for error handling in quite a few places, including
smtpserver, imapserver and web API calls. Functions/methods, variables, struct
fields and types that begin with an "x" indicate they can panic on errors. Both
for i/o errors that are fatal for a connection, and also often for user-induced
errors, for example bad IMAP commands or invalid web API requests. These panics
are caught again at the top of a command or top of the connection. Write code
that is panic-safe, using defer to clean up and release resources.
- Try to check all errors, at the minimum using mlog.Log.Check() to log an error
at the appropriate level. Also when just closing a file. Log messages sometimes
unexpectedly point out latent issues. Only when there is no point in logging,
for example when previous writes to stderr failed, can error logging be skipped.
Test code is less strict about checking errors.
# Reusable packages
@ -28,6 +70,7 @@ with bad API. Third party users aren't affected too seriously due to Go's
minimal version selection. The reusable packages are in apidiff/packages.txt.
We generate the incompatible changes with each release.
# Web interfaces/frontend
The web interface frontends (for webmail/, webadmin/ and webaccount/) are
@ -42,8 +85,8 @@ https://github.com/mjl-/sherpats/.
The JavaScript that is generated from the TypeScript is included in the
repository. This makes it available for inclusion in the binary, which is
practical for users, and desirable given Go's reproducible builds. When
developing, run "make" to also build the frontend code. Run "make jsinstall"
once to install the TypeScript compiler into ./node_modules/.
developing, run "make" to also build the frontend code. Run "make
install-frontend" once to install the TypeScript compiler into ./node_modules/.
There are no other external (runtime or devtime) frontend dependencies. A
light-weight abstraction over the DOM is provided by ./lib.ts. A bit more
@ -54,6 +97,29 @@ TypeScript interface exposing a "root" HTMLElement that is added to the DOM,
and functions for accessing/changing the internal state, keeping the UI
managable.
# Website
The content of the public website at https://www.xmox.nl is in website/, as
markdown files. The website HTML is generated with "make genwebsite", which
writes to website/html/ (files not committed). The FAQ is taken from
README.md, the protocol support table is generated from rfc/index.txt. The
website is kept in this repository so a commit can change both the
implementation and the documentation on the website. Some of the info in
README.md is duplicated on the website, often more elaborate and possibly with
a slightly less technical audience. The website should also mostly be readable
through the markdown in the git repo.
Large files (images/videos) are in https://github.com/mjl-/mox-website-files to
keep the repository reasonably sized.
The public website may serve the content from the "website" branch. After a
release, the main branch (with latest development code and corresponding
changes to the website about new features) is merged into the website branch.
Commits to the website branch (e.g. for a news item, or any other change
unrelated to a new release) is merged back into the main branch.
# TLS certificates
https://github.com/cloudflare/cfssl is useful for testing with TLS
@ -134,6 +200,7 @@ Listeners:
KeyFile: ../../cfssl/wildcard.$domain-key.pem
```
# ACME
https://github.com/letsencrypt/pebble is useful for testing with ACME. Start a
@ -231,7 +298,7 @@ for i in 0 12; do
done
```
With the following "tobmox.sh" script:
With the following "tombox.sh" script:
```
#!/bin/sh
@ -249,21 +316,30 @@ done
- Gather feedback on recent changes.
- Check if dependencies need updates.
- Update to latest publicsuffix/ list.
- Check code if there are deprecated features that can be removed.
- Generate apidiff and check if breaking changes can be prevented.
- Update features & roadmap in README.md
- Write release notes.
- Build and run tests with previous major Go release.
- Run tests, including with race detector.
- Generate apidiff and check if breaking changes can be prevented. Update moxtools.
- Update features & roadmap in README.md and website.
- Write release notes, copy from previous.
- Build and run tests with previous major Go release, run "make docker-release" to test building images.
- Run tests, including with race detector, also with TZ= for UTC-behaviour, and with -count 2.
- Run integration and upgrade tests.
- Run fuzzing tests for a while.
- Deploy to test environment. Test the update instructions.
- Test mox localserve on various OSes (linux, bsd, macos, windows).
- Send and receive email through the major webmail providers, check headers.
- Send and receive email with imap4/smtp clients.
- Check DNS check admin page.
- Check with https://internet.nl
- Create git tag, push code.
- Publish new docker image.
- Publish signed release notes for updates.xmox.nl and update DNS record.
- Publish new cross-referenced code/rfc to www.xmox.nl/xr/.
- Check with https://internet.nl.
- Move apidiff/next.txt to apidiff/<version>.txt, and create empty next.txt.
- Add release to the Latest release & News sections of website/index.md.
- Create git tag (note: "#" is comment, not title/header), push code.
- Build and publish new docker image.
- Deploy update to website.
- Create new release on the github page, so watchers get a notification.
Copy/paste it manually from the tag text, and add link to download/compile
instructions to prevent confusion about "assets" github links to.
- Publish new cross-referenced code/rfc to www.xmox.nl/xr/.
- Update moxtools with latest version.
- Update implementations support matrix.
- Publish signed release notes for updates.xmox.nl and update DNS record.

View File

@ -21,19 +21,22 @@ import (
"fmt"
"hash"
"io"
"log/slog"
"strings"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/publicsuffix"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/stub"
"slices"
)
// If set, signatures for top-level domain "localhost" are accepted.
var Localserve bool
var (
MetricSign stub.CounterVec = stub.CounterVecIgnore{}
MetricVerify stub.HistogramVec = stub.HistogramVecIgnore{}
@ -171,7 +174,7 @@ func Sign(ctx context.Context, elog *slog.Logger, localpart smtp.Localpart, doma
sig.Domain = domain
sig.Selector = sel.Domain
sig.Identity = &Identity{&localpart, domain}
sig.SignedHeaders = append([]string{}, sel.Headers...)
sig.SignedHeaders = slices.Clone(sel.Headers)
if sel.SealHeaders {
// ../rfc/6376:2156
// Each time a header name is added to the signature, the next unused value is
@ -443,7 +446,7 @@ func checkSignatureParams(ctx context.Context, log mlog.Log, sig *Sig) (hash cry
if subdom.Unicode != "" {
subdom.Unicode = "x." + subdom.Unicode
}
if orgDom := publicsuffix.Lookup(ctx, log.Logger, subdom); subdom.ASCII == orgDom.ASCII {
if orgDom := publicsuffix.Lookup(ctx, log.Logger, subdom); subdom.ASCII == orgDom.ASCII && !(Localserve && sig.Domain.ASCII == "localhost") {
return 0, false, false, fmt.Errorf("%w: %s", ErrTLD, sig.Domain)
}
@ -546,7 +549,7 @@ func verifySignatureRecord(r *Record, sig *Sig, hash crypto.Hash, canonHeaderSim
if r.PublicKey == nil {
return StatusPermerror, ErrKeyRevoked
} else if rsaKey, ok := r.PublicKey.(*rsa.PublicKey); ok && rsaKey.N.BitLen() < 1024 {
// todo: find a reference that supports this.
// ../rfc/8301:157
return StatusPermerror, ErrWeakKey
}
@ -837,8 +840,8 @@ func parseHeaders(br *bufio.Reader) ([]header, int, error) {
return nil, 0, fmt.Errorf("empty header key")
}
lkey = strings.ToLower(key)
value = append([]byte{}, t[1]...)
raw = append([]byte{}, line...)
value = slices.Clone(t[1])
raw = slices.Clone(line)
}
if key != "" {
l = append(l, header{key, lkey, value, raw})

View File

@ -6,6 +6,8 @@ import (
"strconv"
"strings"
"golang.org/x/text/unicode/norm"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/smtp"
)
@ -279,7 +281,7 @@ func (p *parser) xlocalpart() smtp.Localpart {
// ../rfc/5321:3486
p.xerrorf("localpart longer than 64 octets")
}
return smtp.Localpart(s)
return smtp.Localpart(norm.NFC.String(s))
}
func (p *parser) xquotedString() string {

View File

@ -117,7 +117,7 @@ func (s *Sig) Header() (string, error) {
} else if i == len(s.SignedHeaders)-1 {
v += ";"
}
w.Addf(sep, v)
w.Addf(sep, "%s", v)
}
}
if len(s.CopiedHeaders) > 0 {
@ -139,7 +139,7 @@ func (s *Sig) Header() (string, error) {
} else if i == len(s.CopiedHeaders)-1 {
v += ";"
}
w.Addf(sep, v)
w.Addf(sep, "%s", v)
}
}
@ -147,7 +147,7 @@ func (s *Sig) Header() (string, error) {
w.Addf(" ", "b=")
if len(s.Signature) > 0 {
w.AddWrap([]byte(base64.StdEncoding.EncodeToString(s.Signature)))
w.AddWrap([]byte(base64.StdEncoding.EncodeToString(s.Signature)), false)
}
w.Add("\r\n")
return w.String(), nil

View File

@ -32,7 +32,7 @@ func TestParseRecord(t *testing.T) {
}
if r != nil {
pk := r.Pubkey
for i := 0; i < 2; i++ {
for range 2 {
ntxt, err := r.Record()
if err != nil {
t.Fatalf("making record: %v", err)

View File

@ -14,7 +14,8 @@ import (
"context"
"errors"
"fmt"
mathrand "math/rand"
"log/slog"
mathrand2 "math/rand/v2"
"time"
"github.com/mjl-/mox/dkim"
@ -23,8 +24,6 @@ import (
"github.com/mjl-/mox/publicsuffix"
"github.com/mjl-/mox/spf"
"github.com/mjl-/mox/stub"
"golang.org/x/exp/slog"
)
var (
@ -258,7 +257,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, msgFr
// Record can request sampling of messages to apply policy.
// See ../rfc/7489:1432
useResult = !applyRandomPercentage || record.Percentage == 100 || mathrand.Intn(100) < record.Percentage
useResult = !applyRandomPercentage || record.Percentage == 100 || mathrand2.IntN(100) < record.Percentage
// We treat "quarantine" and "reject" the same. Thus, we also don't "downgrade"
// from reject to quarantine if this message was sampled out.

View File

@ -3,11 +3,10 @@ package dmarc_test
import (
"context"
"log"
"log/slog"
"net"
"strings"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc"
"github.com/mjl-/mox/dns"
@ -38,7 +37,7 @@ func ExampleVerify() {
// Message to verify.
msg := strings.NewReader("From: <sender@example.com>\r\nMore: headers\r\n\r\nBody\r\n")
msgFrom, _, _, err := message.From(slog.Default(), true, msg)
msgFrom, _, _, err := message.From(slog.Default(), true, msg, nil)
if err != nil {
log.Fatalf("parsing message for from header: %v", err)
}

View File

@ -92,9 +92,9 @@ func parseRecord(s string, checkRequired bool) (record *Record, isdmarc bool, re
// ../rfc/7489:1105
p.xerrorf("p= (policy) must be first tag")
}
r.Policy = DMARCPolicy(p.xtakelist("none", "quarantine", "reject"))
r.Policy = Policy(p.xtakelist("none", "quarantine", "reject"))
case "sp":
r.SubdomainPolicy = DMARCPolicy(p.xkeyword())
r.SubdomainPolicy = Policy(p.xkeyword())
// note: we check if the value is valid before returning.
case "rua":
r.AggregateReportAddresses = append(r.AggregateReportAddresses, p.xuri())

View File

@ -5,18 +5,16 @@ import (
"strings"
)
// todo: DMARCPolicy should be named just Policy, but this is causing conflicting types in sherpadoc output. should somehow get the dmarc-prefix only in the sherpadoc.
// Policy as used in DMARC DNS record for "p=" or "sp=".
type DMARCPolicy string
type Policy string
// ../rfc/7489:1157
const (
PolicyEmpty DMARCPolicy = "" // Only for the optional Record.SubdomainPolicy.
PolicyNone DMARCPolicy = "none"
PolicyQuarantine DMARCPolicy = "quarantine"
PolicyReject DMARCPolicy = "reject"
PolicyEmpty Policy = "" // Only for the optional Record.SubdomainPolicy.
PolicyNone Policy = "none"
PolicyQuarantine Policy = "quarantine"
PolicyReject Policy = "reject"
)
// URI is a destination address for reporting.
@ -56,8 +54,8 @@ const (
// v=DMARC1; p=reject; rua=mailto:postmaster@mox.example
type Record struct {
Version string // "v=DMARC1", fixed.
Policy DMARCPolicy // Required, for "p=".
SubdomainPolicy DMARCPolicy // Like policy but for subdomains. Optional, for "sp=".
Policy Policy // Required, for "p=".
SubdomainPolicy Policy // Like policy but for subdomains. Optional, for "sp=".
AggregateReportAddresses []URI // Optional, for "rua=". Destination addresses for aggregate reports.
FailureReportAddresses []URI // Optional, for "ruf=". Destination addresses for failure reports.
ADKIM Align // Alignment: "r" (default) for relaxed or "s" for simple. For "adkim=".

View File

@ -11,7 +11,17 @@
package dmarcdb
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/mjl-/bstore"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxvar"
)
// Init opens the databases.
@ -19,11 +29,49 @@ import (
// The incoming reports and evaluations for outgoing reports are in separate
// databases for simpler file-based handling of the databases.
func Init() error {
if _, err := reportsDB(mox.Shutdown); err != nil {
return err
if ReportsDB != nil || EvalDB != nil {
return fmt.Errorf("already initialized")
}
if _, err := evalDB(mox.Shutdown); err != nil {
return err
log := mlog.New("dmarcdb", nil)
var err error
ReportsDB, err = openReportsDB(mox.Shutdown, log)
if err != nil {
return fmt.Errorf("open reports db: %v", err)
}
EvalDB, err = openEvalDB(mox.Shutdown, log)
if err != nil {
return fmt.Errorf("open eval db: %v", err)
}
return nil
}
func Close() error {
if err := ReportsDB.Close(); err != nil {
return fmt.Errorf("closing reports db: %w", err)
}
ReportsDB = nil
if err := EvalDB.Close(); err != nil {
return fmt.Errorf("closing eval db: %w", err)
}
EvalDB = nil
return nil
}
func openReportsDB(ctx context.Context, log mlog.Log) (*bstore.DB, error) {
p := mox.DataDirPath("dmarcrpt.db")
os.MkdirAll(filepath.Dir(p), 0770)
opts := bstore.Options{Timeout: 5 * time.Second, Perm: 0660, RegisterLogger: moxvar.RegisterLogger(p, log.Logger)}
return bstore.Open(ctx, p, &opts, ReportsDBTypes...)
}
func openEvalDB(ctx context.Context, log mlog.Log) (*bstore.DB, error) {
p := mox.DataDirPath("dmarceval.db")
os.MkdirAll(filepath.Dir(p), 0770)
opts := bstore.Options{Timeout: 5 * time.Second, Perm: 0660, RegisterLogger: moxvar.RegisterLogger(p, log.Logger)}
return bstore.Open(ctx, p, &opts, EvalDBTypes...)
}

View File

@ -9,22 +9,19 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"maps"
"mime"
"mime/multipart"
"net/textproto"
"net/url"
"os"
"path/filepath"
"runtime/debug"
"sort"
"slices"
"strings"
"sync"
"time"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"golang.org/x/exp/slog"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@ -67,7 +64,6 @@ var (
// to the database. Every hour, a goroutine wakes up that gathers evaluations from
// the last hour(s), sends a report, and removes the evaluations from the database.
EvalDB *bstore.DB
evalMutex sync.Mutex
)
// Evaluation is the result of an evaluation of a DMARC policy, to be included
@ -162,21 +158,6 @@ func (e Evaluation) ReportRecord(count int) dmarcrpt.ReportRecord {
}
}
func evalDB(ctx context.Context) (rdb *bstore.DB, rerr error) {
evalMutex.Lock()
defer evalMutex.Unlock()
if EvalDB == nil {
p := mox.DataDirPath("dmarceval.db")
os.MkdirAll(filepath.Dir(p), 0770)
db, err := bstore.Open(ctx, p, &bstore.Options{Timeout: 5 * time.Second, Perm: 0660}, EvalDBTypes...)
if err != nil {
return nil, err
}
EvalDB = db
}
return EvalDB, nil
}
var intervalOpts = []int{24, 12, 8, 6, 4, 3, 2}
func intervalHours(seconds int) int {
@ -197,23 +178,13 @@ func intervalHours(seconds int) int {
func AddEvaluation(ctx context.Context, aggregateReportingIntervalSeconds int, e *Evaluation) error {
e.IntervalHours = intervalHours(aggregateReportingIntervalSeconds)
db, err := evalDB(ctx)
if err != nil {
return err
}
e.ID = 0
return db.Insert(ctx, e)
return EvalDB.Insert(ctx, e)
}
// Evaluations returns all evaluations in the database.
func Evaluations(ctx context.Context) ([]Evaluation, error) {
db, err := evalDB(ctx)
if err != nil {
return nil, err
}
q := bstore.QueryDB[Evaluation](ctx, db)
q := bstore.QueryDB[Evaluation](ctx, EvalDB)
q.SortAsc("Evaluated")
return q.List()
}
@ -229,14 +200,9 @@ type EvaluationStat struct {
// EvaluationStats returns evaluation counts and report-sending status per domain.
func EvaluationStats(ctx context.Context) (map[string]EvaluationStat, error) {
db, err := evalDB(ctx)
if err != nil {
return nil, err
}
r := map[string]EvaluationStat{}
err = bstore.QueryDB[Evaluation](ctx, db).ForEach(func(e Evaluation) error {
err := bstore.QueryDB[Evaluation](ctx, EvalDB).ForEach(func(e Evaluation) error {
if stat, ok := r[e.PolicyDomain]; ok {
if !slices.Contains(stat.Dispositions, string(e.Disposition)) {
stat.Dispositions = append(stat.Dispositions, string(e.Disposition))
@ -263,12 +229,7 @@ func EvaluationStats(ctx context.Context) (map[string]EvaluationStat, error) {
// EvaluationsDomain returns all evaluations for a domain.
func EvaluationsDomain(ctx context.Context, domain dns.Domain) ([]Evaluation, error) {
db, err := evalDB(ctx)
if err != nil {
return nil, err
}
q := bstore.QueryDB[Evaluation](ctx, db)
q := bstore.QueryDB[Evaluation](ctx, EvalDB)
q.FilterNonzero(Evaluation{PolicyDomain: domain.Name()})
q.SortAsc("Evaluated")
return q.List()
@ -277,14 +238,9 @@ func EvaluationsDomain(ctx context.Context, domain dns.Domain) ([]Evaluation, er
// RemoveEvaluationsDomain removes evaluations for domain so they won't be sent in
// an aggregate report.
func RemoveEvaluationsDomain(ctx context.Context, domain dns.Domain) error {
db, err := evalDB(ctx)
if err != nil {
return err
}
q := bstore.QueryDB[Evaluation](ctx, db)
q := bstore.QueryDB[Evaluation](ctx, EvalDB)
q.FilterNonzero(Evaluation{PolicyDomain: domain.Name()})
_, err = q.Delete()
_, err := q.Delete()
return err
}
@ -294,7 +250,7 @@ var jitterRand = mox.NewPseudoRand()
// Jitter so we don't cause load at exactly whole hours, other processes may
// already be doing that.
var jitteredTimeUntil = func(t time.Time) time.Duration {
return time.Until(t.Add(time.Duration(30+jitterRand.Intn(60)) * time.Second))
return time.Until(t.Add(time.Duration(30+jitterRand.IntN(60)) * time.Second))
}
// Start launches a goroutine that wakes up at each whole hour (plus jitter) and
@ -318,12 +274,6 @@ func Start(resolver dns.Resolver) {
ctx := mox.Shutdown
db, err := evalDB(ctx)
if err != nil {
log.Errorx("opening dmarc evaluations database for sending dmarc aggregate reports, not sending reports", err)
return
}
for {
now := time.Now()
nextEnd := nextWholeHour(now)
@ -355,12 +305,12 @@ func Start(resolver dns.Resolver) {
// 24 hour interval). They should have been processed by now. We may have kept them
// during temporary errors, but persistent temporary errors shouldn't fill up our
// database. This also cleans up evaluations that were all optional for a domain.
_, err := bstore.QueryDB[Evaluation](ctx, db).FilterLess("Evaluated", nextEnd.Add(-48*time.Hour)).Delete()
_, err := bstore.QueryDB[Evaluation](ctx, EvalDB).FilterLess("Evaluated", nextEnd.Add(-48*time.Hour)).Delete()
log.Check(err, "removing stale dmarc evaluations from database")
clog := log.WithCid(mox.Cid())
clog.Info("sending dmarc aggregate reports", slog.Time("end", nextEnd.UTC()), slog.Any("intervals", intervals))
if err := sendReports(ctx, clog, resolver, db, nextEnd, intervals); err != nil {
if err := sendReports(ctx, clog, resolver, EvalDB, nextEnd, intervals); err != nil {
clog.Errorx("sending dmarc aggregate reports", err)
metricReportError.Inc()
} else {
@ -737,9 +687,7 @@ func sendReportDomain(ctx context.Context, log mlog.Log, resolver dns.Resolver,
report.PolicyPublished = last.PolicyPublished
// Process records in-order for testable results.
recstrs := maps.Keys(counts)
sort.Strings(recstrs)
for _, recstr := range recstrs {
for _, recstr := range slices.Sorted(maps.Keys(counts)) {
rc := counts[recstr]
rc.ReportRecord.Row.Count = rc.count
report.Records = append(report.Records, rc.ReportRecord)
@ -779,7 +727,7 @@ func sendReportDomain(ctx context.Context, log mlog.Log, resolver dns.Resolver,
// DKIM keys, so we can DKIM-sign our reports. SPF should pass anyway.
// A single report can contain deliveries from a single policy domain
// to multiple of our configured domains.
from := smtp.Address{Localpart: "postmaster", Domain: mox.Conf.Static.HostnameDomain}
from := smtp.NewAddress("postmaster", mox.Conf.Static.HostnameDomain)
// Subject follows the form in RFC. ../rfc/7489:1871
subject := fmt.Sprintf("Report Domain: %s Submitter: %s Report-ID: <%s>", dom.ASCII, mox.Conf.Static.HostnameDomain.ASCII, report.ReportMetadata.ReportID)
@ -842,13 +790,13 @@ Period: %s - %s UTC
continue
}
qm := queue.MakeMsg(mox.Conf.Static.Postmaster.Account, from.Path(), rcpt.address.Path(), has8bit, smtputf8, msgSize, messageID, []byte(msgPrefix), nil)
qm := queue.MakeMsg(from.Path(), rcpt.address.Path(), has8bit, smtputf8, msgSize, messageID, []byte(msgPrefix), nil, time.Now(), subject)
// Don't try as long as regular deliveries, and stop before we would send the
// delayed DSN. Though we also won't send that due to IsDMARCReport.
qm.MaxAttempts = 5
qm.IsDMARCReport = true
err = queueAdd(ctx, log, &qm, msgf)
err = queueAdd(ctx, log, mox.Conf.Static.Postmaster.Account, msgf, qm)
if err != nil {
tempError = true
log.Errorx("queueing message with dmarc aggregate report", err)
@ -875,7 +823,15 @@ Period: %s - %s UTC
}
func composeAggregateReport(ctx context.Context, log mlog.Log, mf *os.File, fromAddr smtp.Address, recipients []message.NameAddress, subject, text, filename string, reportXMLGzipFile *os.File) (msgPrefix string, has8bit, smtputf8 bool, messageID string, rerr error) {
xc := message.NewComposer(mf, 100*1024*1024)
// We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
smtputf8 = fromAddr.Localpart.IsInternational()
for _, r := range recipients {
if smtputf8 {
smtputf8 = r.Address.Localpart.IsInternational()
break
}
}
xc := message.NewComposer(mf, 100*1024*1024, smtputf8)
defer func() {
x := recover()
if x == nil {
@ -888,14 +844,6 @@ func composeAggregateReport(ctx context.Context, log mlog.Log, mf *os.File, from
panic(x)
}()
// We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
for _, a := range recipients {
if a.Address.Localpart.IsInternational() {
xc.SMTPUTF8 = true
break
}
}
xc.HeaderAddrs("From", []message.NameAddress{{Address: fromAddr}})
xc.HeaderAddrs("To", recipients)
xc.Subject(subject)
@ -911,7 +859,7 @@ func composeAggregateReport(ctx context.Context, log mlog.Log, mf *os.File, from
xc.Line()
// Textual part, just mentioning this is a DMARC report.
textBody, ct, cte := xc.TextPart(text)
textBody, ct, cte := xc.TextPart("plain", text)
textHdr := textproto.MIMEHeader{}
textHdr.Set("Content-Type", ct)
textHdr.Set("Content-Transfer-Encoding", cte)
@ -997,13 +945,13 @@ Submitting-URI: %s
continue
}
qm := queue.MakeMsg(mox.Conf.Static.Postmaster.Account, fromAddr.Path(), rcpt.Address.Path(), has8bit, smtputf8, msgSize, messageID, []byte(msgPrefix), nil)
qm := queue.MakeMsg(fromAddr.Path(), rcpt.Address.Path(), has8bit, smtputf8, msgSize, messageID, []byte(msgPrefix), nil, time.Now(), subject)
// Don't try as long as regular deliveries, and stop before we would send the
// delayed DSN. Though we also won't send that due to IsDMARCReport.
qm.MaxAttempts = 5
qm.IsDMARCReport = true
if err := queueAdd(ctx, log, &qm, msgf); err != nil {
if err := queueAdd(ctx, log, mox.Conf.Static.Postmaster.Account, msgf, qm); err != nil {
log.Errorx("queueing message with dmarc error report", err)
metricReportError.Inc()
} else {
@ -1015,7 +963,15 @@ Submitting-URI: %s
}
func composeErrorReport(ctx context.Context, log mlog.Log, mf *os.File, fromAddr smtp.Address, recipients []message.NameAddress, subject, text string) (msgPrefix string, has8bit, smtputf8 bool, messageID string, rerr error) {
xc := message.NewComposer(mf, 100*1024*1024)
// We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
smtputf8 = fromAddr.Localpart.IsInternational()
for _, r := range recipients {
if smtputf8 {
smtputf8 = r.Address.Localpart.IsInternational()
break
}
}
xc := message.NewComposer(mf, 100*1024*1024, smtputf8)
defer func() {
x := recover()
if x == nil {
@ -1028,14 +984,6 @@ func composeErrorReport(ctx context.Context, log mlog.Log, mf *os.File, fromAddr
panic(x)
}()
// We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
for _, a := range recipients {
if a.Address.Localpart.IsInternational() {
xc.SMTPUTF8 = true
break
}
}
xc.HeaderAddrs("From", []message.NameAddress{{Address: fromAddr}})
xc.HeaderAddrs("To", recipients)
xc.Header("Subject", subject)
@ -1045,7 +993,7 @@ func composeErrorReport(ctx context.Context, log mlog.Log, mf *os.File, fromAddr
xc.Header("User-Agent", "mox/"+moxvar.Version)
xc.Header("MIME-Version", "1.0")
textBody, ct, cte := xc.TextPart(text)
textBody, ct, cte := xc.TextPart("plain", text)
xc.Header("Content-Type", ct)
xc.Header("Content-Transfer-Encoding", cte)
xc.Line()
@ -1069,7 +1017,7 @@ func dkimSign(ctx context.Context, log mlog.Log, fromAddr smtp.Address, smtputf8
for fd != zerodom {
confDom, ok := mox.Conf.Domain(fd)
selectors := mox.DKIMSelectors(confDom.DKIM)
if len(selectors) > 0 {
if len(selectors) > 0 && !confDom.Disabled {
dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Localpart, fd, selectors, smtputf8, mf)
if err != nil {
log.Errorx("dkim-signing dmarc report, continuing without signature", err)
@ -1091,46 +1039,26 @@ func dkimSign(ctx context.Context, log mlog.Log, fromAddr smtp.Address, smtputf8
// SuppressAdd adds an address to the suppress list.
func SuppressAdd(ctx context.Context, ba *SuppressAddress) error {
db, err := evalDB(ctx)
if err != nil {
return err
}
return db.Insert(ctx, ba)
return EvalDB.Insert(ctx, ba)
}
// SuppressList returns all reporting addresses on the suppress list.
func SuppressList(ctx context.Context) ([]SuppressAddress, error) {
db, err := evalDB(ctx)
if err != nil {
return nil, err
}
return bstore.QueryDB[SuppressAddress](ctx, db).SortDesc("ID").List()
return bstore.QueryDB[SuppressAddress](ctx, EvalDB).SortDesc("ID").List()
}
// SuppressRemove removes a reporting address record from the suppress list.
func SuppressRemove(ctx context.Context, id int64) error {
db, err := evalDB(ctx)
if err != nil {
return err
}
return db.Delete(ctx, &SuppressAddress{ID: id})
return EvalDB.Delete(ctx, &SuppressAddress{ID: id})
}
// SuppressUpdate updates the until field of a reporting address record.
func SuppressUpdate(ctx context.Context, id int64, until time.Time) error {
db, err := evalDB(ctx)
if err != nil {
return err
}
ba := SuppressAddress{ID: id}
err = db.Get(ctx, &ba)
err := EvalDB.Get(ctx, &ba)
if err != nil {
return err
}
ba.Until = until
return db.Update(ctx, &ba)
return EvalDB.Update(ctx, &ba)
}

View File

@ -13,14 +13,13 @@ import (
"testing"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dmarcrpt"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/queue"
"slices"
)
func tcheckf(t *testing.T, err error, format string, args ...any) {
@ -42,13 +41,13 @@ func TestEvaluations(t *testing.T) {
mox.Context = ctxbg
mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
mox.MustLoadConfig(true, false)
EvalDB = nil
_, err := evalDB(ctxbg)
tcheckf(t, err, "database")
os.Remove(mox.DataDirPath("dmarceval.db"))
err := Init()
tcheckf(t, err, "init")
defer func() {
EvalDB.Close()
EvalDB = nil
err := Close()
tcheckf(t, err, "close")
}()
parseJSON := func(s string) (e Evaluation) {
@ -158,19 +157,17 @@ func TestEvaluations(t *testing.T) {
}
func TestSendReports(t *testing.T) {
mlog.SetConfig(map[string]slog.Level{"": slog.LevelDebug})
os.RemoveAll("../testdata/dmarcdb/data")
mox.Context = ctxbg
mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
mox.MustLoadConfig(true, false)
EvalDB = nil
db, err := evalDB(ctxbg)
tcheckf(t, err, "database")
os.Remove(mox.DataDirPath("dmarceval.db"))
err := Init()
tcheckf(t, err, "init")
defer func() {
EvalDB.Close()
EvalDB = nil
err := Close()
tcheckf(t, err, "close")
}()
resolver := dns.MockResolver{
@ -289,18 +286,23 @@ func TestSendReports(t *testing.T) {
mox.Shutdown, mox.ShutdownCancel = context.WithCancel(ctxbg)
for _, e := range evals {
err := db.Insert(ctxbg, &e)
err := EvalDB.Insert(ctxbg, &e)
tcheckf(t, err, "inserting evaluation")
}
aggrAddrs := map[string]struct{}{}
errorAddrs := map[string]struct{}{}
queueAdd = func(ctx context.Context, log mlog.Log, qm *queue.Msg, msgFile *os.File) error {
queueAdd = func(ctx context.Context, log mlog.Log, senderAccount string, msgFile *os.File, qml ...queue.Msg) error {
if len(qml) != 1 {
return fmt.Errorf("queued %d messages, expected 1", len(qml))
}
qm := qml[0]
// Read message file. Also write copy to disk for inspection.
buf, err := io.ReadAll(&moxio.AtReader{R: msgFile})
tcheckf(t, err, "read report message")
err = os.WriteFile("../testdata/dmarcdb/data/report.eml", append(append([]byte{}, qm.MsgPrefix...), buf...), 0600)
err = os.WriteFile("../testdata/dmarcdb/data/report.eml", slices.Concat(qm.MsgPrefix, buf), 0600)
tcheckf(t, err, "write report message")
var feedback *dmarcrpt.Feedback
@ -355,13 +357,13 @@ func TestSendReports(t *testing.T) {
// Address is suppressed.
sa := SuppressAddress{ReportingAddress: "dmarcrpt@sender.example", Until: time.Now().Add(time.Minute)}
err = db.Insert(ctxbg, &sa)
err = EvalDB.Insert(ctxbg, &sa)
tcheckf(t, err, "insert suppress address")
test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
// Suppression has expired.
sa.Until = time.Now().Add(-time.Minute)
err = db.Update(ctxbg, &sa)
err = EvalDB.Update(ctxbg, &sa)
tcheckf(t, err, "update suppress address")
test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)

17
dmarcdb/main_test.go Normal file
View File

@ -0,0 +1,17 @@
package dmarcdb
import (
"fmt"
"os"
"testing"
"github.com/mjl-/mox/metrics"
)
func TestMain(m *testing.M) {
m.Run()
if metrics.Panics.Load() > 0 {
fmt.Println("unhandled panics encountered")
os.Exit(2)
}
}

View File

@ -3,9 +3,6 @@ package dmarcdb
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
@ -15,13 +12,11 @@ import (
"github.com/mjl-/mox/dmarcrpt"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
)
var (
ReportsDBTypes = []any{DomainFeedback{}} // Types stored in DB.
ReportsDB *bstore.DB // Exported for backups.
reportsMutex sync.Mutex
)
var (
@ -59,38 +54,18 @@ type DomainFeedback struct {
dmarcrpt.Feedback
}
func reportsDB(ctx context.Context) (rdb *bstore.DB, rerr error) {
reportsMutex.Lock()
defer reportsMutex.Unlock()
if ReportsDB == nil {
p := mox.DataDirPath("dmarcrpt.db")
os.MkdirAll(filepath.Dir(p), 0770)
db, err := bstore.Open(ctx, p, &bstore.Options{Timeout: 5 * time.Second, Perm: 0660}, ReportsDBTypes...)
if err != nil {
return nil, err
}
ReportsDB = db
}
return ReportsDB, nil
}
// AddReport adds a DMARC aggregate feedback report from an email to the database,
// and updates prometheus metrics.
//
// fromDomain is the domain in the report message From header.
func AddReport(ctx context.Context, f *dmarcrpt.Feedback, fromDomain dns.Domain) error {
db, err := reportsDB(ctx)
if err != nil {
return err
}
d, err := dns.ParseDomain(f.PolicyPublished.Domain)
if err != nil {
return fmt.Errorf("parsing domain in report: %v", err)
}
df := DomainFeedback{0, d.Name(), fromDomain.Name(), *f}
if err := db.Insert(ctx, &df); err != nil {
if err := ReportsDB.Insert(ctx, &df); err != nil {
return err
}
@ -129,38 +104,23 @@ func AddReport(ctx context.Context, f *dmarcrpt.Feedback, fromDomain dns.Domain)
// Records returns all reports in the database.
func Records(ctx context.Context) ([]DomainFeedback, error) {
db, err := reportsDB(ctx)
if err != nil {
return nil, err
}
return bstore.QueryDB[DomainFeedback](ctx, db).List()
return bstore.QueryDB[DomainFeedback](ctx, ReportsDB).List()
}
// RecordID returns the report for the ID.
func RecordID(ctx context.Context, id int64) (DomainFeedback, error) {
db, err := reportsDB(ctx)
if err != nil {
return DomainFeedback{}, err
}
e := DomainFeedback{ID: id}
err = db.Get(ctx, &e)
err := ReportsDB.Get(ctx, &e)
return e, err
}
// RecordsPeriodDomain returns the reports overlapping start and end, for the given
// domain. If domain is empty, all records match for domain.
func RecordsPeriodDomain(ctx context.Context, start, end time.Time, domain string) ([]DomainFeedback, error) {
db, err := reportsDB(ctx)
if err != nil {
return nil, err
}
s := start.Unix()
e := end.Unix()
q := bstore.QueryDB[DomainFeedback](ctx, db)
q := bstore.QueryDB[DomainFeedback](ctx, ReportsDB)
if domain != "" {
q.FilterNonzero(DomainFeedback{Domain: domain})
}

View File

@ -20,16 +20,12 @@ func TestDMARCDB(t *testing.T) {
mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
mox.MustLoadConfig(true, false)
dbpath := mox.DataDirPath("dmarcrpt.db")
os.MkdirAll(filepath.Dir(dbpath), 0770)
if err := Init(); err != nil {
t.Fatalf("init database: %s", err)
}
defer os.Remove(dbpath)
os.Remove(mox.DataDirPath("dmarcrpt.db"))
err := Init()
tcheckf(t, err, "init")
defer func() {
ReportsDB.Close()
ReportsDB = nil
err := Close()
tcheckf(t, err, "close")
}()
feedback := &dmarcrpt.Feedback{

View File

@ -47,6 +47,8 @@ type PolicyPublished struct {
type Alignment string
const (
AlignmentAbsent Alignment = ""
AlignmentRelaxed Alignment = "r" // Subdomains match the DMARC from-domain.
AlignmentStrict Alignment = "s" // Only exact from-domain match.
)
@ -56,6 +58,8 @@ const (
type Disposition string
const (
DispositionAbsent Disposition = ""
DispositionNone Disposition = "none"
DispositionQuarantine Disposition = "quarantine"
DispositionReject Disposition = "reject"
@ -87,6 +91,8 @@ type PolicyEvaluated struct {
type DMARCResult string
const (
DMARCAbsent DMARCResult = ""
DMARCPass DMARCResult = "pass"
DMARCFail DMARCResult = "fail"
)
@ -101,6 +107,8 @@ type PolicyOverrideReason struct {
type PolicyOverride string
const (
PolicyOverrideAbsent PolicyOverride = ""
PolicyOverrideForwarded PolicyOverride = "forwarded"
PolicyOverrideSampledOut PolicyOverride = "sampled_out"
PolicyOverrideTrustedForwarder PolicyOverride = "trusted_forwarder"
@ -130,6 +138,8 @@ type DKIMAuthResult struct {
type DKIMResult string
const (
DKIMAbsent DKIMResult = ""
DKIMNone DKIMResult = "none"
DKIMPass DKIMResult = "pass"
DKIMFail DKIMResult = "fail"
@ -148,6 +158,8 @@ type SPFAuthResult struct {
type SPFDomainScope string
const (
SPFDomainScopeAbsent SPFDomainScope = ""
SPFDomainScopeHelo SPFDomainScope = "helo" // SMTP EHLO
SPFDomainScopeMailFrom SPFDomainScope = "mfrom" // SMTP "MAIL FROM".
)
@ -155,6 +167,8 @@ const (
type SPFResult string
const (
SPFAbsent SPFResult = ""
SPFNone SPFResult = "none"
SPFNeutral SPFResult = "neutral"
SPFPass SPFResult = "pass"

View File

@ -9,11 +9,10 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/moxio"
@ -53,7 +52,7 @@ func parseMessageReport(log mlog.Log, p message.Part) (*Feedback, error) {
// content of the message.
if p.MediaType != "MULTIPART" {
return parseReport(p)
return parseReport(log, p)
}
for {
@ -73,7 +72,7 @@ func parseMessageReport(log mlog.Log, p message.Part) (*Feedback, error) {
}
}
func parseReport(p message.Part) (*Feedback, error) {
func parseReport(log mlog.Log, p message.Part) (*Feedback, error) {
ct := strings.ToLower(p.MediaType + "/" + p.MediaSubType)
r := p.Reader()
@ -94,7 +93,7 @@ func parseReport(p message.Part) (*Feedback, error) {
switch ct {
case "application/zip":
// Google sends messages with direct application/zip content-type.
return parseZip(r)
return parseZip(log, r)
case "application/gzip", "application/x-gzip":
gzr, err := gzip.NewReader(r)
if err != nil {
@ -107,7 +106,7 @@ func parseReport(p message.Part) (*Feedback, error) {
return nil, ErrNoReport
}
func parseZip(r io.Reader) (*Feedback, error) {
func parseZip(log mlog.Log, r io.Reader) (*Feedback, error) {
buf, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("reading feedback: %s", err)
@ -123,6 +122,9 @@ func parseZip(r io.Reader) (*Feedback, error) {
if err != nil {
return nil, fmt.Errorf("opening file in zip: %s", err)
}
defer f.Close()
defer func() {
err := f.Close()
log.Check(err, "closing report file in zip file")
}()
return ParseReport(f)
}

View File

@ -5,6 +5,7 @@ package dns
import (
"errors"
"fmt"
"net"
"strings"
"golang.org/x/net/idna"
@ -19,6 +20,7 @@ var (
errTrailingDot = errors.New("dns name has trailing dot")
errUnderscore = errors.New("domain name with underscore")
errIDNA = errors.New("idna")
errIPNotName = errors.New("ip address while name required")
)
// Domain is a domain name, with one or more labels, with at least an ASCII
@ -30,7 +32,8 @@ type Domain struct {
// letters/digits/hyphens) labels. Always in lower case. No trailing dot.
ASCII string
// Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot.
// Name as U-labels, in Unicode NFC. Empty if this is an ASCII-only domain. No
// trailing dot.
Unicode string
}
@ -87,12 +90,19 @@ func (d Domain) IsZero() bool {
// labels (unicode).
// Names are IDN-canonicalized and lower-cased.
// Characters in unicode can be replaced by equivalents. E.g. "Ⓡ" to "r". This
// means you should only compare parsed domain names, never strings directly.
// means you should only compare parsed domain names, never unparsed strings
// directly.
func ParseDomain(s string) (Domain, error) {
if strings.HasSuffix(s, ".") {
return Domain{}, errTrailingDot
}
// IPv4 addresses would be accepted by idna lookups. TLDs cannot be all numerical,
// so IP addresses are not valid DNS names.
if net.ParseIP(s) != nil {
return Domain{}, errIPNotName
}
ascii, err := idna.Lookup.ToASCII(s)
if err != nil {
return Domain{}, fmt.Errorf("%w: to ascii: %v", errIDNA, err)
@ -146,7 +156,9 @@ func ParseDomainLax(s string) (Domain, error) {
return Domain{ASCII: s}, nil
}
// IsNotFound returns whether an error is an adns.DNSError with IsNotFound set.
// IsNotFound returns whether an error is an adns.DNSError or net.DNSError with
// IsNotFound set.
//
// IsNotFound means the requested type does not exist for the given domain (a
// nodata or nxdomain response). It doesn't not necessarily mean no other types for
// that name exist.
@ -156,6 +168,7 @@ func ParseDomainLax(s string) (Domain, error) {
// The adns resolver (just like the Go resolver) returns an IsNotFound error for
// both cases, there is no need to explicitly check for zero entries.
func IsNotFound(err error) bool {
var dnsErr *adns.DNSError
return err != nil && errors.As(err, &dnsErr) && dnsErr.IsNotFound
var adnsErr *adns.DNSError
var dnsErr *net.DNSError
return err != nil && (errors.As(err, &adnsErr) && adnsErr.IsNotFound || errors.As(err, &dnsErr) && dnsErr.IsNotFound)
}

View File

@ -4,8 +4,7 @@ import (
"context"
"fmt"
"net"
"golang.org/x/exp/slices"
"slices"
"github.com/mjl-/adns"
)

View File

@ -4,14 +4,13 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"os"
"runtime"
"strings"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/adns"
"github.com/mjl-/mox/mlog"

View File

@ -10,7 +10,7 @@
// looked up with an DNS "A" lookup of a name similar to an IPv4 address, but with
// 4-bit hexadecimal dot-separated characters, in reverse.
//
// The health of a DNSBL "zone" can be check through a lookup of 127.0.0.1
// The health of a DNSBL "zone" can be checked through a lookup of 127.0.0.1
// (must not be present) and 127.0.0.2 (must be present).
package dnsbl
@ -18,13 +18,12 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"strconv"
"strings"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/stub"

View File

@ -3,10 +3,9 @@ package dnsbl_test
import (
"context"
"log"
"log/slog"
"net"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/dnsbl"
)

877
doc.go

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,12 @@
version: '3.7'
services:
mox:
build:
context: .
dockerfile: Dockerfile.moximaptest
volumes:
- ./testdata/imaptest/config:/mox/config
- ./testdata/imaptest/data:/mox/data
- ./testdata/imaptest/imaptest.mbox:/mox/imaptest.mbox
- ./testdata/imaptest/config:/mox/config:z
- ./testdata/imaptest/data:/mox/data:z
- ./testdata/imaptest/imaptest.mbox:/mox/imaptest.mbox:z
working_dir: /mox
tty: true # For job control with set -m.
command: sh -c 'set -m; mox serve & sleep 1; echo testtest | mox setaccountpassword mjl; fg'
@ -24,7 +23,7 @@ services:
command: host=mox port=1143 'user=mjl@mox.example' pass=testtest mbox=/imaptest/imaptest.mbox
working_dir: /imaptest
volumes:
- ./testdata/imaptest:/imaptest
- ./testdata/imaptest:/imaptest:z
depends_on:
mox:
condition: service_healthy

View File

@ -1,4 +1,3 @@
version: '3.7'
services:
# We run integration_test.go from this container, it connects to the other mox instances.
test:
@ -9,11 +8,11 @@ services:
# dials in integration_test.go succeed.
command: ["sh", "-c", "set -ex; cat /integration/tmp-pebble-ca.pem /integration/tls/ca.pem >>/etc/ssl/certs/ca-certificates.crt; go test -tags integration"]
volumes:
- ./.go:/.go
- ./testdata/integration/resolv.conf:/etc/resolv.conf
- ./testdata/integration:/integration
- ./testdata/integration/moxsubmit.conf:/etc/moxsubmit.conf
- .:/mox
- ./.go:/.go:z
- ./testdata/integration/resolv.conf:/etc/resolv.conf:z
- ./testdata/integration:/integration:z
- ./testdata/integration/moxsubmit.conf:/etc/moxsubmit.conf:z
- .:/mox:z
environment:
GOCACHE: /.go/.cache/go-build
depends_on:
@ -26,6 +25,8 @@ services:
condition: service_healthy
localserve:
condition: service_healthy
moxacmepebblealpn:
condition: service_healthy
networks:
mailnet1:
ipv4_address: 172.28.1.50
@ -39,8 +40,8 @@ services:
MOX_UID: "${MOX_UID}"
command: ["sh", "-c", "/integration/moxacmepebble.sh"]
volumes:
- ./testdata/integration/resolv.conf:/etc/resolv.conf
- ./testdata/integration:/integration
- ./testdata/integration/resolv.conf:/etc/resolv.conf:z
- ./testdata/integration:/integration:z
healthcheck:
test: netstat -nlt | grep ':25 '
interval: 1s
@ -64,8 +65,8 @@ services:
MOX_UID: "${MOX_UID}"
command: ["sh", "-c", "/integration/moxmail2.sh"]
volumes:
- ./testdata/integration/resolv.conf:/etc/resolv.conf
- ./testdata/integration:/integration
- ./testdata/integration/resolv.conf:/etc/resolv.conf:z
- ./testdata/integration:/integration:z
healthcheck:
test: netstat -nlt | grep ':25 '
interval: 1s
@ -83,15 +84,40 @@ services:
mailnet1:
ipv4_address: 172.28.1.20
# Third mox instance that uses ACME with pebble and has ALPN enabled.
moxacmepebblealpn:
hostname: moxacmepebblealpn.mox1.example
domainname: mox1.example
image: mox_integration_moxmail
environment:
MOX_UID: "${MOX_UID}"
command: ["sh", "-c", "/integration/moxacmepebblealpn.sh"]
volumes:
- ./testdata/integration/resolv.conf:/etc/resolv.conf:z
- ./testdata/integration:/integration:z
healthcheck:
test: netstat -nlt | grep ':25 '
interval: 1s
timeout: 1s
retries: 10
depends_on:
dns:
condition: service_healthy
acmepebble:
condition: service_healthy
networks:
mailnet1:
ipv4_address: 172.28.1.80
localserve:
hostname: localserve.mox1.example
domainname: mox1.example
image: mox_integration_moxmail
command: ["sh", "-c", "set -e; chmod o+r /etc/resolv.conf; mox -checkconsistency localserve -ip 172.28.1.60"]
volumes:
- ./.go:/.go
- ./testdata/integration/resolv.conf:/etc/resolv.conf
- .:/mox
- ./.go:/.go:z
- ./testdata/integration/resolv.conf:/etc/resolv.conf:z
- .:/mox:z
environment:
GOCACHE: /.go/.cache/go-build
healthcheck:
@ -114,7 +140,7 @@ services:
context: testdata/integration
volumes:
# todo: figure out how to mount files with a uid that the process in the container can read...
- ./testdata/integration/resolv.conf:/etc/resolv.conf
- ./testdata/integration/resolv.conf:/etc/resolv.conf:z
command: ["sh", "-c", "set -e; chmod o+r /etc/resolv.conf; (echo 'maillog_file = /dev/stdout'; echo 'mydestination = $$myhostname, localhost.$$mydomain, localhost, $$mydomain'; echo 'smtp_tls_security_level = may') >>/etc/postfix/main.cf; echo 'root: postfix@mox1.example' >>/etc/postfix/aliases; newaliases; postfix start-fg"]
healthcheck:
test: netstat -nlt | grep ':25 '
@ -135,8 +161,8 @@ services:
# todo: figure out how to build from dockerfile with empty context without creating empty dirs in file system.
context: testdata/integration
volumes:
- ./testdata/integration/resolv.conf:/etc/resolv.conf
- ./testdata/integration:/integration
- ./testdata/integration/resolv.conf:/etc/resolv.conf:z
- ./testdata/integration:/integration:z
# We start with a base example.zone, but moxacmepebble appends its records,
# followed by moxmail2. They restart unbound after appending records.
command: ["sh", "-c", "set -ex; ls -l /etc/resolv.conf; chmod o+r /etc/resolv.conf; install -m 640 -o unbound /integration/unbound.conf /etc/unbound/; chmod 755 /integration; chmod 644 /integration/*.zone; cp /integration/example.zone /integration/example-integration.zone; ls -ld /integration /integration/reverse.zone; unbound -d -p -v"]
@ -156,8 +182,8 @@ services:
hostname: acmepebble.example
image: docker.io/letsencrypt/pebble:v2.3.1@sha256:fc5a537bf8fbc7cc63aa24ec3142283aa9b6ba54529f86eb8ff31fbde7c5b258
volumes:
- ./testdata/integration/resolv.conf:/etc/resolv.conf
- ./testdata/integration:/integration
- ./testdata/integration/resolv.conf:/etc/resolv.conf:z
- ./testdata/integration:/integration:z
command: ["sh", "-c", "set -ex; mount; ls -l /etc/resolv.conf; chmod o+r /etc/resolv.conf; pebble -config /integration/pebble-config.json"]
ports:
- 14000:14000 # ACME port

View File

@ -27,7 +27,6 @@
# The -ip flag ensures connections to the published ports make it to mox, and it
# prevents listening on ::1 (IPv6 is not enabled in docker by default).
version: '3.7'
services:
mox:
# Replace "latest" with the version you want to run, see https://r.xmox.nl/r/mox/.
@ -39,11 +38,11 @@ services:
# machine, and the IPs of incoming connections for spam filtering.
network_mode: 'host'
volumes:
- ./config:/mox/config
- ./data:/mox/data
- ./config:/mox/config:z
- ./data:/mox/data:z
# web is optional but recommended to bind in, useful for serving static files with
# the webserver.
- ./web:/mox/web
- ./web:/mox/web:z
working_dir: /mox
restart: on-failure
healthcheck:

View File

@ -11,7 +11,6 @@ import (
"io"
"mime/multipart"
"net/textproto"
"strconv"
"strings"
"time"
@ -48,6 +47,12 @@ type Message struct {
// mail user-agents will thread the DSN with the original message.
References string
// For message submitted with FUTURERELEASE SMTP extension. Value is either "for;"
// plus original interval in seconds or "until;" plus original UTC RFC3339
// date-time.
FutureReleaseRequest string
// ../rfc/4865:315
// Human-readable text explaining the failure. Line endings should be
// bare newlines, not \r\n. They are converted to \r\n when composing.
TextBody string
@ -94,9 +99,10 @@ type Recipient struct {
Action Action
// Enhanced status code. First digit indicates permanent or temporary
// error. If the string contains more than just a status, that
// additional text is added as comment when composing a DSN.
// error.
Status string
// For additional details, included in comment.
StatusComment string
// Optional fields.
// Original intended recipient of message. Used with the DSN extensions ORCPT
@ -108,10 +114,10 @@ type Recipient struct {
// deliveries.
RemoteMTA NameIP
// If RemoteMTA is present, DiagnosticCode is from remote. When
// creating a DSN, additional text in the string will be added to the
// DSN as comment.
DiagnosticCode string
// DiagnosticCodeSMTP are the full SMTP response lines, space separated. The marshaled
// form starts with "smtp; ", this value does not.
DiagnosticCodeSMTP string
LastAttemptDate time.Time
FinalLogID string
@ -230,6 +236,10 @@ func (m *Message) Compose(log mlog.Log, smtputf8 bool) ([]byte, error) {
status("Received-From-MTA", fmt.Sprintf("dns;%s (%s)", m.ReceivedFromMTA.Name, smtp.AddressLiteral(m.ReceivedFromMTA.ConnIP)))
}
status("Arrival-Date", m.ArrivalDate.Format(message.RFC5322Z)) // ../rfc/3464:758
if m.FutureReleaseRequest != "" {
// ../rfc/4865:320
status("Future-Release-Request", m.FutureReleaseRequest)
}
// Then per-recipient fields. ../rfc/3464:769
// todo: should also handle other address types. at least recognize "unknown". Probably just store this field. ../rfc/3464:819
@ -262,11 +272,9 @@ func (m *Message) Compose(log mlog.Log, smtputf8 bool) ([]byte, error) {
st = "2.0.0"
}
}
var rest string
st, rest = codeLine(st)
statusLine := st
if rest != "" {
statusLine += " (" + rest + ")"
if r.StatusComment != "" {
statusLine += " (" + r.StatusComment + ")"
}
status("Status", statusLine) // ../rfc/3464:975
if !r.RemoteMTA.IsZero() {
@ -278,14 +286,9 @@ func (m *Message) Compose(log mlog.Log, smtputf8 bool) ([]byte, error) {
status("Remote-MTA", s)
}
// Presence of Diagnostic-Code indicates the code is from Remote-MTA. ../rfc/3464:1053
if r.DiagnosticCode != "" {
diagCode, rest := codeLine(r.DiagnosticCode)
diagLine := diagCode
if rest != "" {
diagLine += " (" + rest + ")"
}
// ../rfc/6533:589
status("Diagnostic-Code", "smtp; "+diagLine)
if r.DiagnosticCodeSMTP != "" {
// ../rfc/3461:1342 ../rfc/6533:589
status("Diagnostic-Code", "smtp; "+r.DiagnosticCodeSMTP)
}
if !r.LastAttemptDate.IsZero() {
status("Last-Attempt-Date", r.LastAttemptDate.Format(message.RFC5322Z)) // ../rfc/3464:1076
@ -337,10 +340,7 @@ func (m *Message) Compose(log mlog.Log, smtputf8 bool) ([]byte, error) {
data := base64.StdEncoding.EncodeToString(headers)
for len(data) > 0 {
line := data
n := len(line)
if n > 78 {
n = 78
}
n := min(len(line), 76) // ../rfc/2045:1372
line, data = data[:n], data[n:]
if _, err := origp.Write([]byte(line + "\r\n")); err != nil {
return nil, err
@ -378,34 +378,3 @@ func (w *errWriter) Write(buf []byte) (int, error) {
w.err = err
return n, err
}
// split a line into enhanced status code and rest.
func codeLine(s string) (string, string) {
t := strings.SplitN(s, " ", 2)
l := strings.Split(t[0], ".")
if len(l) != 3 {
return "", s
}
for i, e := range l {
_, err := strconv.ParseInt(e, 10, 32)
if err != nil {
return "", s
}
if i == 0 && len(e) != 1 {
return "", s
}
}
var rest string
if len(t) == 2 {
rest = t[1]
}
return t[0], rest
}
// HasCode returns whether line starts with an enhanced SMTP status code.
func HasCode(line string) bool {
// ../rfc/3464:986
ecode, _ := codeLine(line)
return ecode != ""
}

View File

@ -50,8 +50,8 @@ func tcheckType(t *testing.T, p *message.Part, mt, mst, cte string) {
if !strings.EqualFold(p.MediaSubType, mst) {
t.Fatalf("got mediasubtype %q, expected %q", p.MediaSubType, mst)
}
if !strings.EqualFold(p.ContentTransferEncoding, cte) {
t.Fatalf("got content-transfer-encoding %q, expected %q", p.ContentTransferEncoding, cte)
if !(cte == "" && p.ContentTransferEncoding == nil || cte != "" && p.ContentTransferEncoding != nil && strings.EqualFold(cte, *p.ContentTransferEncoding)) {
t.Fatalf("got content-transfer-encoding %v, expected %v", p.ContentTransferEncoding, cte)
}
}
@ -88,6 +88,7 @@ func TestDSN(t *testing.T) {
ReportingMTA: "mox.example",
ReceivedFromMTA: smtp.Ehlo{Name: xparseIPDomain("relay.example"), ConnIP: net.ParseIP("10.10.10.10")},
ArrivalDate: now,
FutureReleaseRequest: "for;123",
Recipients: []Recipient{
{
@ -191,34 +192,3 @@ func TestDSN(t *testing.T) {
tcheckType(t, &part.Parts[1], "message", "global-delivery-status", "8bit")
tcompare(t, pmsg.Recipients[0].FinalRecipient, m.Recipients[0].FinalRecipient)
}
func TestCode(t *testing.T) {
testCodeLine := func(line, ecode, rest string) {
t.Helper()
e, r := codeLine(line)
if e != ecode || r != rest {
t.Fatalf("codeLine %q: got %q %q, expected %q %q", line, e, r, ecode, rest)
}
}
testCodeLine("4.0.0", "4.0.0", "")
testCodeLine("4.0.0 more", "4.0.0", "more")
testCodeLine("other", "", "other")
testCodeLine("other more", "", "other more")
testHasCode := func(line string, exp bool) {
t.Helper()
got := HasCode(line)
if got != exp {
t.Fatalf("HasCode %q: got %v, expected %v", line, got, exp)
}
}
testHasCode("4.0.0", true)
testHasCode("5.7.28", true)
testHasCode("10.0.0", false) // first number must be single digit.
testHasCode("4.1.1 more", true)
testHasCode("other ", false)
testHasCode("4.2.", false)
testHasCode("4.2. ", false)
testHasCode(" 4.2.4", false)
testHasCode(" 4.2.4 ", false)
}

View File

@ -4,17 +4,17 @@ import (
"bufio"
"fmt"
"io"
"log/slog"
"net/textproto"
"strconv"
"strings"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/smtp"
"slices"
)
// Parse reads a DSN message.
@ -66,7 +66,11 @@ func Parse(elog *slog.Logger, r io.ReaderAt) (*Message, *message.Part, error) {
if err != nil {
return smtp.Path{}, fmt.Errorf("parsing domain: %v", err)
}
return smtp.Path{Localpart: smtp.Localpart(a.User), IPDomain: dns.IPDomain{Domain: d}}, nil
lp, err := smtp.ParseLocalpart(a.User)
if err != nil {
return smtp.Path{}, fmt.Errorf("parsing localpart: %v", err)
}
return smtp.Path{Localpart: lp, IPDomain: dns.IPDomain{Domain: d}}, nil
}
if len(part.Envelope.From) == 1 {
m.From, err = addressPath(part.Envelope.From[0])
@ -214,19 +218,21 @@ func parseRecipientHeader(mr *textproto.Reader, utf8 bool) (Recipient, error) {
case "Action":
a := Action(strings.ToLower(v))
actions := []Action{Failed, Delayed, Delivered, Relayed, Expanded}
var ok bool
for _, x := range actions {
if a == x {
ok = true
break
}
}
if !ok {
if slices.Contains(actions, a) {
r.Action = a
} else {
err = fmt.Errorf("unrecognized action %q", v)
}
case "Status":
// todo: parse the enhanced status code?
r.Status = v
t := strings.SplitN(v, "(", 2)
v = strings.TrimSpace(v)
if len(t) == 2 && strings.HasSuffix(v, ")") {
r.Status = strings.TrimSpace(t[0])
r.StatusComment = strings.TrimSpace(strings.TrimSuffix(t[1], ")"))
}
case "Remote-Mta":
r.RemoteMTA = NameIP{Name: v}
case "Diagnostic-Code":
@ -238,7 +244,7 @@ func parseRecipientHeader(mr *textproto.Reader, utf8 bool) (Recipient, error) {
} else if len(t) != 2 {
err = fmt.Errorf("missing semicolon to separate diagnostic-type from code")
} else {
r.DiagnosticCode = strings.TrimSpace(t[1])
r.DiagnosticCodeSMTP = strings.TrimSpace(t[1])
}
case "Last-Attempt-Date":
r.LastAttemptDate, err = parseDateTime(v)
@ -311,17 +317,18 @@ func parseAddress(s string, utf8 bool) (smtp.Path, error) {
}
}
// todo: more proper parser
t = strings.SplitN(s, "@", 2)
if len(t) != 2 || t[0] == "" || t[1] == "" {
t = strings.Split(s, "@")
if len(t) == 1 {
return smtp.Path{}, fmt.Errorf("invalid email address")
}
d, err := dns.ParseDomain(t[1])
d, err := dns.ParseDomain(t[len(t)-1])
if err != nil {
return smtp.Path{}, fmt.Errorf("parsing domain: %v", err)
}
var lp string
var esc string
for _, c := range t[0] {
lead := strings.Join(t[:len(t)-1], "@")
for _, c := range lead {
if esc == "" && c == '\\' || esc == `\` && (c == 'x' || c == 'X') || esc == `\x` && c == '{' {
if c == 'X' {
c = 'x'
@ -345,7 +352,11 @@ func parseAddress(s string, utf8 bool) (smtp.Path, error) {
if esc != "" {
return smtp.Path{}, fmt.Errorf("parsing localpart: unfinished embedded unicode char")
}
p := smtp.Path{Localpart: smtp.Localpart(lp), IPDomain: dns.IPDomain{Domain: d}}
localpart, err := smtp.ParseLocalpart(lp)
if err != nil {
return smtp.Path{}, fmt.Errorf("parsing localpart: %v", err)
}
p := smtp.Path{Localpart: localpart, IPDomain: dns.IPDomain{Domain: d}}
return p, nil
}

View File

@ -1,13 +1,21 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"reflect"
"strings"
"time"
"github.com/mjl-/sconf"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/webhook"
)
func cmdExample(c *cmd) {
@ -36,7 +44,33 @@ func cmdExample(c *cmd) {
fmt.Print(match())
}
var examples = []struct {
func cmdConfigExample(c *cmd) {
c.params = "[name]"
c.help = `List available config examples, or print a specific example.`
args := c.Parse()
if len(args) > 1 {
c.Usage()
}
var match func() string
for _, ex := range configExamples {
if len(args) == 0 {
fmt.Println(ex.Name)
} else if args[0] == ex.Name {
match = ex.Get
}
}
if len(args) == 0 {
return
}
if match == nil {
log.Fatalln("not found")
}
fmt.Print(match())
}
var configExamples = []struct {
Name string
Get func() string
}{
@ -135,7 +169,7 @@ WebHandlers:
"transport",
func() string {
const moxconf = `# Snippet for mox.conf, defining a transport called Example that connects on the
# SMTP submission with TLS port 465 ("submissions), authenticating with
# SMTP submission with TLS port 465 ("submissions"), authenticating with
# SCRAM-SHA-256-PLUS (other providers may not support SCRAM-SHA-256-PLUS, but they
# typically do support the older CRAM-MD5).:
@ -195,3 +229,97 @@ Routes:
},
},
}
var exampleTime = time.Date(2024, time.March, 27, 0, 0, 0, 0, time.UTC)
var examples = []struct {
Name string
Get func() string
}{
{
"webhook-outgoing-delivered",
func() string {
v := webhook.Outgoing{
Version: 0,
Event: webhook.EventDelivered,
QueueMsgID: 101,
FromID: base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef")),
MessageID: "<QnxzgulZK51utga6agH_rg@mox.example>",
Subject: "subject of original message",
WebhookQueued: exampleTime,
Extra: map[string]string{},
SMTPCode: smtp.C250Completed,
}
return "Example webhook HTTP POST JSON body for successful outgoing delivery:\n\n\t" + formatJSON(v)
},
},
{
"webhook-outgoing-dsn-failed",
func() string {
v := webhook.Outgoing{
Version: 0,
Event: webhook.EventFailed,
DSN: true,
Suppressing: true,
QueueMsgID: 102,
FromID: base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef")),
MessageID: "<QnxzgulZK51utga6agH_rg@mox.example>",
Subject: "subject of original message",
WebhookQueued: exampleTime,
Extra: map[string]string{"userid": "456"},
Error: "timeout connecting to host",
SMTPCode: smtp.C554TransactionFailed,
SMTPEnhancedCode: "5." + smtp.SeNet4Other0,
}
return `Example webhook HTTP POST JSON body for failed delivery based on incoming DSN
message, with custom extra data fields (from original submission), and adding address to the suppression list:
` + formatJSON(v)
},
},
{
"webhook-incoming-basic",
func() string {
v := webhook.Incoming{
Version: 0,
From: []webhook.NameAddress{{Address: "mox@localhost"}},
To: []webhook.NameAddress{{Address: "mjl@localhost"}},
Subject: "hi",
MessageID: "<QnxzgulZK51utga6agH_rg@mox.example>",
Date: &exampleTime,
Text: "hello world ☺\n",
Structure: webhook.Structure{
ContentType: "text/plain",
ContentTypeParams: map[string]string{"charset": "utf-8"},
DecodedSize: int64(len("hello world ☺\r\n")),
Parts: []webhook.Structure{},
},
Meta: webhook.IncomingMeta{
MsgID: 201,
MailFrom: "mox@localhost",
MailFromValidated: false,
MsgFromValidated: true,
RcptTo: "mjl@localhost",
DKIMVerifiedDomains: []string{"localhost"},
RemoteIP: "127.0.0.1",
Received: exampleTime.Add(3 * time.Second),
MailboxName: "Inbox",
Automated: false,
},
}
return "Example JSON body for webhooks for incoming delivery of basic message:\n\n\t" + formatJSON(v)
},
},
}
func formatJSON(v any) string {
nv, _ := mox.FillNil(reflect.ValueOf(v))
v = nv.Interface()
var b bytes.Buffer
enc := json.NewEncoder(&b)
enc.SetIndent("\t", "\t")
enc.SetEscapeHTML(false)
err := enc.Encode(v)
xcheckf(err, "encoding to json")
return b.String()
}

View File

@ -12,20 +12,22 @@ import (
)
func cmdExportMaildir(c *cmd) {
c.params = "dst-dir account-path [mailbox]"
c.params = "[-single] dst-dir account-path [mailbox]"
c.help = `Export one or all mailboxes from an account in maildir format.
Export bypasses a running mox instance. It opens the account mailbox/message
database file directly. This may block if a running mox instance also has the
database open, e.g. for IMAP connections. To export from a running instance, use
the accounts web page.
the accounts web page or webmail.
`
var single bool
c.flag.BoolVar(&single, "single", false, "export single mailbox, without any children. disabled if mailbox isn't specified.")
args := c.Parse()
xcmdExport(false, args, c)
xcmdExport(false, single, args, c)
}
func cmdExportMbox(c *cmd) {
c.params = "dst-dir account-path [mailbox]"
c.params = "[-single] dst-dir account-path [mailbox]"
c.help = `Export messages from one or all mailboxes in an account in mbox format.
Using mbox is not recommended. Maildir is a better format.
@ -33,17 +35,19 @@ Using mbox is not recommended. Maildir is a better format.
Export bypasses a running mox instance. It opens the account mailbox/message
database file directly. This may block if a running mox instance also has the
database open, e.g. for IMAP connections. To export from a running instance, use
the accounts web page.
the accounts web page or webmail.
For mbox export, "mboxrd" is used where message lines starting with the magic
"From " string are escaped by prepending a >. All ">*From " are escaped,
otherwise reconstructing the original could lose a ">".
`
var single bool
c.flag.BoolVar(&single, "single", false, "export single mailbox, without any children. disabled if mailbox isn't specified.")
args := c.Parse()
xcmdExport(true, args, c)
xcmdExport(true, single, args, c)
}
func xcmdExport(mbox bool, args []string, c *cmd) {
func xcmdExport(mbox, single bool, args []string, c *cmd) {
if len(args) != 2 && len(args) != 3 {
c.Usage()
}
@ -53,10 +57,13 @@ func xcmdExport(mbox bool, args []string, c *cmd) {
var mailbox string
if len(args) == 3 {
mailbox = args[2]
} else {
single = false
}
dbpath := filepath.Join(accountDir, "index.db")
db, err := bstore.Open(context.Background(), dbpath, &bstore.Options{Timeout: 5 * time.Second, Perm: 0660}, store.DBTypes...)
opts := bstore.Options{Timeout: 5 * time.Second, Perm: 0660, RegisterLogger: c.log.Logger}
db, err := bstore.Open(context.Background(), dbpath, &opts, store.DBTypes...)
xcheckf(err, "open database %q", dbpath)
defer func() {
if err := db.Close(); err != nil {
@ -65,7 +72,7 @@ func xcmdExport(mbox bool, args []string, c *cmd) {
}()
a := store.DirArchiver{Dir: dst}
err = store.ExportMessages(context.Background(), c.log, db, accountDir, a, !mbox, mailbox)
err = store.ExportMessages(context.Background(), c.log, db, accountDir, a, !mbox, mailbox, nil, !single)
xcheckf(err, "exporting messages")
err = a.Close()
xcheckf(err, "closing archiver")

View File

@ -1,4 +0,0 @@
#!/bin/sh
# change output to regular filename:linenumber format for easier opening.
arg=$(echo $1 | sed 's,/,\\/,')
exec sed "s/^\([^:]*\): line \([0-9][0-9]*\), \(.*\)\$/${arg}\1:\2: \3/"

10
genapidoc.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/sh
set -eu
# we rewrite some dmarcprt and tlsrpt enums into untyped strings: real-world
# reports have invalid values, and our loose Go typed strings accept all values,
# but we don't want the typescript runtime checker to fail on those unrecognized
# values.
(cd webadmin && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none -rename 'config Domain ConfigDomain,dmarc Policy DMARCPolicy,mtasts MX STSMX,tlsrptdb Record TLSReportRecord,tlsrptdb SuppressAddress TLSRPTSuppressAddress,dmarcrpt DKIMResult string,dmarcrpt SPFResult string,dmarcrpt SPFDomainScope string,dmarcrpt DMARCResult string,dmarcrpt PolicyOverride string,dmarcrpt Alignment string,dmarcrpt Disposition string,tlsrpt PolicyType string,tlsrpt ResultType string' Admin) >webadmin/api.json
(cd webaccount && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none Account) >webaccount/api.json
(cd webmail && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none Webmail) >webmail/api.json

View File

@ -1,28 +1,34 @@
#!/bin/sh
#!/usr/bin/env sh
# ./doc.go
(
cat <<EOF
/*
Command mox is a modern, secure, full-featured, open source mail server for
low-maintenance self-hosted email.
# Commands
Mox is started with the "serve" subcommand, but mox also has many other
subcommands.
Many of those commands talk to a running mox instance, through the ctl file in
the data directory. Specify the configuration file (that holds the path to the
data directory) through the -config flag or MOXCONF environment variable.
Commands that don't talk to a running mox instance are often for
testing/debugging email functionality. For example for parsing an email message,
or looking up SPF/DKIM/DMARC records.
Below is the usage information as printed by the command when started without
any parameters. Followed by the help and usage information for each command.
# Usage
EOF
./mox 2>&1 | sed 's/^\( *\|usage: \)/\t/'
cat <<EOF
Many commands talk to a running mox instance, through the ctl file in the data
directory. Specify the configuration file (that holds the path to the data
directory) through the -config flag or MOXCONF environment variable.
EOF
# setting XDG_CONFIG_HOME ensures "mox localserve" has reasonable default
# values in its help output.
XDG_CONFIG_HOME='$userconfigdir' ./mox helpall 2>&1
./mox 2>&1 | sed -e 's/^usage: */ /' -e 's/^ */ /'
echo
./mox helpall 2>&1
cat <<EOF
*/
@ -33,47 +39,70 @@ EOF
)>doc.go
gofmt -w doc.go
# ./config/doc.go
(
cat <<EOF
/*
Package config holds the configuration file definitions for mox.conf (Static)
and domains.conf (Dynamic).
Package config holds the configuration file definitions.
These config files are in "sconf" format. Summarized: Indent with tabs, "#" as
first non-whitespace character makes the line a comment (you cannot have a line
with both a value and a comment), strings are not quoted/escaped and can never
span multiple lines. See https://pkg.go.dev/github.com/mjl-/sconf for details.
Mox uses two config files:
1. mox.conf, also called the static configuration file.
2. domains.conf, also called the dynamic configuration file.
The static configuration file is never reloaded during the lifetime of a
running mox instance. After changes to mox.conf, mox must be restarted for the
changes to take effect.
The dynamic configuration file is reloaded automatically when it changes.
If the file contains an error after the change, the reload is aborted and the
previous version remains active.
Below are "empty" config files, generated from the config file definitions in
the source code, along with comments explaining the fields. Fields named "x" are
placeholders for user-chosen map keys.
# sconf
The config files are in "sconf" format. Properties of sconf files:
- Indentation with tabs only.
- "#" as first non-whitespace character makes the line a comment. Lines with a
value cannot also have a comment.
- Values don't have syntax indicating their type. For example, strings are
not quoted/escaped and can never span multiple lines.
- Fields that are optional can be left out completely. But the value of an
optional field may itself have required fields.
See https://pkg.go.dev/github.com/mjl-/sconf for details.
Annotated empty/default configuration files you could use as a starting point
for your mox.conf and domains.conf, as generated by "mox config
describe-static" and "mox config describe-domains":
# mox.conf
EOF
./mox config describe-static | sed 's/^/\t/'
./mox config describe-static | sed 's/^/ /'
cat <<EOF
# domains.conf
EOF
./mox config describe-domains | sed 's/^/\t/'
./mox config describe-domains | sed 's/^/ /'
cat <<EOF
# Examples
Mox includes configuration files to illustrate common setups. You can see these
examples with "mox example", and print a specific example with "mox example
<name>". Below are all examples included in mox.
examples with "mox config example", and print a specific example with "mox
config example <name>". Below are all examples included in mox.
EOF
for ex in $(./mox example); do
for ex in $(./mox config example); do
echo '# Example '$ex
echo
./mox example $ex | sed 's/^/\t/'
./mox config example $ex | sed 's/^/ /'
echo
done
@ -85,3 +114,7 @@ package config
EOF
)>config/doc.go
gofmt -w config/doc.go
# ./webapi/doc.go
./webapi/gendoc.sh >webapi/doc.go
gofmt -w webapi/doc.go

7
genlicenses.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/sh
rm -r licenses
set -e
for p in $(cd vendor && find . -iname '*license*' -or -iname '*licence*' -or -iname '*notice*' -or -iname '*patent*'); do
(set +e; mkdir -p $(dirname licenses/$p))
cp vendor/$p licenses/$p
done

View File

@ -30,7 +30,7 @@ import (
func cmdGentestdata(c *cmd) {
c.unlisted = true
c.params = "dest-dir"
c.params = "destdir"
c.help = `Generate a data directory populated, for testing upgrades.`
args := c.Parse()
if len(args) != 1 {
@ -187,6 +187,12 @@ Accounts:
err = os.WriteFile(filepath.Join(destDataDir, "moxversion"), []byte(moxvar.Version), 0660)
xcheckf(err, "writing moxversion")
// Populate auth.db
err = store.Init(ctxbg)
xcheckf(err, "store init")
err = store.TLSPublicKeyAdd(ctxbg, &store.TLSPublicKey{Name: "testkey", Fingerprint: "...", Type: "ecdsa-p256", CertDER: []byte("..."), Account: "test0", LoginAddress: "test0@mox.example"})
xcheckf(err, "adding tlspubkey")
// Populate dmarc.db.
err = dmarcdb.Init()
xcheckf(err, "dmarcdb init")
@ -201,7 +207,7 @@ Accounts:
mtastsPolicy := mtasts.Policy{
Version: "STSv1",
Mode: mtasts.ModeTesting,
MX: []mtasts.STSMX{
MX: []mtasts.MX{
{Domain: dns.Domain{ASCII: "mx1.example.com"}},
{Domain: dns.Domain{ASCII: "mx2.example.com"}},
{Domain: dns.Domain{ASCII: "backup-example.com"}, Wildcard: true},
@ -228,18 +234,17 @@ Accounts:
prefix := []byte{}
mf := tempfile()
xcheckf(err, "temp file for queue message")
defer os.Remove(mf.Name())
defer mf.Close()
defer store.CloseRemoveTempFile(c.log, mf, "test message")
const qmsg = "From: <test0@mox.example>\r\nTo: <other@remote.example>\r\nSubject: test\r\n\r\nthe message...\r\n"
_, err = fmt.Fprint(mf, qmsg)
xcheckf(err, "writing message")
qm := queue.MakeMsg("test0", mailfrom, rcptto, false, false, int64(len(qmsg)), "<test@localhost>", prefix, nil)
err = queue.Add(ctxbg, c.log, &qm, mf)
qm := queue.MakeMsg(mailfrom, rcptto, false, false, int64(len(qmsg)), "<test@localhost>", prefix, nil, time.Now(), "test")
err = queue.Add(ctxbg, c.log, "test0", mf, qm)
xcheckf(err, "enqueue message")
// Create three accounts.
// First account without messages.
accTest0, err := store.OpenAccount(c.log, "test0")
accTest0, err := store.OpenAccount(c.log, "test0", false)
xcheckf(err, "open account test0")
err = accTest0.ThreadingWait(c.log)
xcheckf(err, "wait for threading to finish")
@ -247,7 +252,7 @@ Accounts:
xcheckf(err, "close account")
// Second account with one message.
accTest1, err := store.OpenAccount(c.log, "test1")
accTest1, err := store.OpenAccount(c.log, "test1", false)
xcheckf(err, "open account test1")
err = accTest1.ThreadingWait(c.log)
xcheckf(err, "wait for threading to finish")
@ -258,7 +263,6 @@ Accounts:
m := store.Message{
MailboxID: inbox.ID,
MailboxOrigID: inbox.ID,
MailboxDestinedID: inbox.ID,
RemoteIP: "1.2.3.4",
RemoteIPMasked1: "1.2.3.4",
RemoteIPMasked2: "1.2.3.0",
@ -283,20 +287,13 @@ Accounts:
}
mf := tempfile()
xcheckf(err, "creating temp file for delivery")
defer store.CloseRemoveTempFile(c.log, mf, "test message")
_, err = fmt.Fprint(mf, msg)
xcheckf(err, "writing deliver message to file")
err = accTest1.DeliverMessage(c.log, tx, &m, mf, false, true, false, true)
mfname := mf.Name()
xcheckf(err, "add message to account test1")
err = mf.Close()
xcheckf(err, "closing file")
err = os.Remove(mfname)
xcheckf(err, "removing temp message file")
err = accTest1.MessageAdd(c.log, tx, &inbox, &m, mf, store.AddOpts{})
xcheckf(err, "deliver message")
err = tx.Get(&inbox)
xcheckf(err, "get inbox")
inbox.Add(m.MailboxCounts())
err = tx.Update(&inbox)
xcheckf(err, "update inbox")
@ -307,7 +304,7 @@ Accounts:
xcheckf(err, "close account")
// Third account with two messages and junkfilter.
accTest2, err := store.OpenAccount(c.log, "test2")
accTest2, err := store.OpenAccount(c.log, "test2", false)
xcheckf(err, "open account test2")
err = accTest2.ThreadingWait(c.log)
xcheckf(err, "wait for threading to finish")
@ -318,7 +315,6 @@ Accounts:
m0 := store.Message{
MailboxID: inbox.ID,
MailboxOrigID: inbox.ID,
MailboxDestinedID: inbox.ID,
RemoteIP: "::1",
RemoteIPMasked1: "::",
RemoteIPMasked2: "::",
@ -343,20 +339,11 @@ Accounts:
}
mf0 := tempfile()
xcheckf(err, "creating temp file for delivery")
defer store.CloseRemoveTempFile(c.log, mf0, "test message")
_, err = fmt.Fprint(mf0, msg0)
xcheckf(err, "writing deliver message to file")
err = accTest2.DeliverMessage(c.log, tx, &m0, mf0, false, false, false, true)
err = accTest2.MessageAdd(c.log, tx, &inbox, &m0, mf0, store.AddOpts{})
xcheckf(err, "add message to account test2")
mf0name := mf0.Name()
err = mf0.Close()
xcheckf(err, "closing file")
err = os.Remove(mf0name)
xcheckf(err, "removing temp message file")
err = tx.Get(&inbox)
xcheckf(err, "get inbox")
inbox.Add(m0.MailboxCounts())
err = tx.Update(&inbox)
xcheckf(err, "update inbox")
@ -367,27 +354,17 @@ Accounts:
m1 := store.Message{
MailboxID: sent.ID,
MailboxOrigID: sent.ID,
MailboxDestinedID: sent.ID,
Flags: store.Flags{Seen: true, Junk: true},
Size: int64(len(prefix1) + len(msg1)),
MsgPrefix: []byte(prefix1),
}
mf1 := tempfile()
xcheckf(err, "creating temp file for delivery")
defer store.CloseRemoveTempFile(c.log, mf1, "test message")
_, err = fmt.Fprint(mf1, msg1)
xcheckf(err, "writing deliver message to file")
err = accTest2.DeliverMessage(c.log, tx, &m1, mf1, false, false, false, true)
err = accTest2.MessageAdd(c.log, tx, &sent, &m1, mf1, store.AddOpts{})
xcheckf(err, "add message to account test2")
mf1name := mf1.Name()
err = mf1.Close()
xcheckf(err, "closing file")
err = os.Remove(mf1name)
xcheckf(err, "removing temp message file")
err = tx.Get(&sent)
xcheckf(err, "get sent")
sent.Add(m1.MailboxCounts())
err = tx.Update(&sent)
xcheckf(err, "update sent")

View File

@ -1,5 +1,5 @@
#!/bin/bash
set -euo pipefail
#!/bin/sh
set -eu
# generate new typescript client, only install it when it is different, so we
# don't trigger frontend builds needlessly.

117
genwebsite.sh Executable file
View File

@ -0,0 +1,117 @@
#!/usr/bin/env bash
mkdir website/html 2>/dev/null
rm -r website/html/* 2>/dev/null
set -euo pipefail
commithash=$(git rev-parse --short HEAD)
commitdate=$(git log -1 --date=format:"%Y-%m-%d" --format="%ad")
export commithash
export commitdate
# Link to static files and cross-references.
ln -sf ../../../mox-website-files/files website/html/files
ln -sf ../../rfc/xr website/html/xr
# All commands below are executed relative to ./website/
cd website
go run website.go -root -title 'Mox: modern, secure, all-in-one mail server' 'Mox' < index.md >html/index.html
mkdir html/features
(
cat features/index.md
echo
sed -n -e 's/^# Roadmap/## Roadmap/' -e '/# FAQ/q' -e '/# Roadmap/,/# FAQ/p' < ../README.md
echo
echo 'Also see the [Protocols](../protocols/) page for implementation status, and (non)-plans.'
) | go run website.go 'Features' >html/features/index.html
mkdir html/screenshots
go run website.go 'Screenshots' < screenshots/index.md >html/screenshots/index.html
mkdir html/install
go run website.go 'Install' < install/index.md >html/install/index.html
mkdir html/faq
sed -n '/# FAQ/,//p' < ../README.md | go run website.go 'FAQ' >html/faq/index.html
mkdir html/config
(
echo '# Config reference'
echo
sed -n '/^Package config holds /,/\*\//p' < ../config/doc.go | grep -v -E '^(Package config holds |\*/)' | sed 's/^# /## /'
) | go run website.go 'Config reference' >html/config/index.html
mkdir html/commands
(
echo '# Command reference'
echo
sed -n '/^Mox is started /,/\*\//p' < ../doc.go | grep -v '\*/' | sed 's/^# /## /'
) | go run website.go 'Command reference' >html/commands/index.html
mkdir html/protocols
go run website.go -protocols 'Protocols' <../rfc/index.txt >html/protocols/index.html
mkdir html/b
cat <<'EOF' >html/b/index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>mox build</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="noNeedlessFaviconRequestsPlease:" />
<style>
body { padding: 1em; }
* { font-size: 18px; font-family: ubuntu, lato, sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
p { max-width: 50em; margin-bottom: 2ex; }
pre { font-family: 'ubuntu mono', monospace; }
pre, blockquote { padding: 1em; background-color: #eee; border-radius: .25em; display: inline-block; margin-bottom: 1em; }
h1 { margin: 1em 0 .5em 0; }
</style>
</head>
<body>
<script>
const elem = (name, ...s) => {
const e = document.createElement(name)
e.append(...s)
return e
}
const link = (url, anchor) => {
const e = document.createElement('a')
e.setAttribute('href', url)
e.setAttribute('rel', 'noopener')
e.append(anchor || url)
return e
}
let h = location.hash.substring(1)
const ok = /^[a-zA-Z0-9_\.]+$/.test(h)
if (!ok) {
h = '<tag-or-branch-or-commithash>'
}
const init = () => {
document.body.append(
elem('p', 'Compile or download any version of mox, by tag (release), branch or commit hash.'),
elem('h1', 'Compile'),
elem('p', 'Run:'),
elem('pre', 'CGO_ENABLED=0 GOBIN=$PWD go install github.com/mjl-/mox@'+h),
elem('p', 'Mox is tested with the Go toolchain versions that are still have support: The most recent version, and the version before.'),
elem('h1', 'Download'),
elem('p', 'Download a binary for your platform:'),
elem('blockquote', ok ?
link('https://beta.gobuilds.org/github.com/mjl-/mox@'+h) :
'https://beta.gobuilds.org/github.com/mjl-/mox@'+h
),
elem('p', 'Because mox is written in Go, builds are reproducible, also when cross-compiling. Gobuilds.org is a service that builds Go applications on-demand with the latest Go toolchain/runtime.'),
elem('h1', 'Localserve'),
elem('p', 'Changes to mox can often be most easily tested locally with ', link('../features/#hdr-localserve', '"mox localserve"'), ', without having to update your running mail server.'),
)
}
window.addEventListener('load', init)
</script>
</body>
</html>
EOF

32
go.mod
View File

@ -1,22 +1,24 @@
module github.com/mjl-/mox
go 1.20
go 1.23.0
require (
github.com/mjl-/adns v0.0.0-20231109160910-82839fe3e6ae
github.com/mjl-/autocert v0.0.0-20231013072455-c361ae2e20a6
github.com/mjl-/bstore v0.0.4
github.com/mjl-/sconf v0.0.5
github.com/mjl-/adns v0.0.0-20250321173553-ab04b05bdfea
github.com/mjl-/autocert v0.0.0-20250321204043-abab2b936e31
github.com/mjl-/bstore v0.0.9
github.com/mjl-/flate v0.0.0-20250221133712-6372d09eb978
github.com/mjl-/sconf v0.0.7
github.com/mjl-/sherpa v0.6.7
github.com/mjl-/sherpadoc v0.0.12
github.com/mjl-/sherpadoc v0.0.16
github.com/mjl-/sherpaprom v0.0.2
github.com/mjl-/sherpats v0.0.5
github.com/mjl-/sherpats v0.0.6
github.com/prometheus/client_golang v1.18.0
go.etcd.io/bbolt v1.3.8
golang.org/x/crypto v0.17.0
golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc
golang.org/x/net v0.19.0
golang.org/x/text v0.14.0
github.com/russross/blackfriday/v2 v2.1.0
go.etcd.io/bbolt v1.3.11
golang.org/x/crypto v0.37.0
golang.org/x/net v0.39.0
golang.org/x/sys v0.32.0
golang.org/x/text v0.24.0
rsc.io/qr v0.2.0
)
@ -28,8 +30,8 @@ require (
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/tools v0.16.1 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/tools v0.32.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)

66
go.sum
View File

@ -16,30 +16,33 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/mjl-/adns v0.0.0-20231109160910-82839fe3e6ae h1:P/kTaQbDFSbmDK+RVjwu7mPyr6a6XyHqHu1PlRMEbAU=
github.com/mjl-/adns v0.0.0-20231109160910-82839fe3e6ae/go.mod h1:v47qUMJnipnmDTRGaHwpCwzE6oypa5K33mUvBfzZBn8=
github.com/mjl-/autocert v0.0.0-20231013072455-c361ae2e20a6 h1:TEXyTghAN9pmV2ffzdnhmzkML08e1Z/oGywJ9eunbRI=
github.com/mjl-/autocert v0.0.0-20231013072455-c361ae2e20a6/go.mod h1:taMFU86abMxKLPV4Bynhv8enbYmS67b8LG80qZv2Qus=
github.com/mjl-/bstore v0.0.4 h1:q+R1oAr8+E9yf9q+zxkVjQ18VFqD/E9KmGVoe4FIOBA=
github.com/mjl-/bstore v0.0.4/go.mod h1:/cD25FNBaDfvL/plFRxI3Ba3E+wcB0XVOS8nJDqndg0=
github.com/mjl-/sconf v0.0.5 h1:4CMUTENpSnaeP2g6RKtrs8udTxnJgjX2MCCovxGId6s=
github.com/mjl-/sconf v0.0.5/go.mod h1:uF8OdWtLT8La3i4ln176i1pB0ps9pXGCaABEU55ZkE0=
github.com/mjl-/adns v0.0.0-20250321173553-ab04b05bdfea h1:8dftsVL1tHhRksXzFZRhSJ7gSlcy/t87Nvucs3JnTGE=
github.com/mjl-/adns v0.0.0-20250321173553-ab04b05bdfea/go.mod h1:rWZMqGA2HoBm5b5q/A5J8u1sSVuEYh6zBz9tMoVs+RU=
github.com/mjl-/autocert v0.0.0-20250321204043-abab2b936e31 h1:6MFGOLPGf6VzHWkKv8waSzJMMS98EFY2LVKPRHffCyo=
github.com/mjl-/autocert v0.0.0-20250321204043-abab2b936e31/go.mod h1:taMFU86abMxKLPV4Bynhv8enbYmS67b8LG80qZv2Qus=
github.com/mjl-/bstore v0.0.9 h1:j8HVXL10Arbk4ujeRGwns8gipH1N1TZn853inQ42FgY=
github.com/mjl-/bstore v0.0.9/go.mod h1:xzIpSfcFosgPJ6h+vsdIt0pzCq4i8hhMuHPQJ0aHQhM=
github.com/mjl-/flate v0.0.0-20250221133712-6372d09eb978 h1:Eg5DfI3/00URzGErujKus6a3O0kyXzF8vjoDZzH/gig=
github.com/mjl-/flate v0.0.0-20250221133712-6372d09eb978/go.mod h1:QBkFtjai3AiQQuUu7pVh6PA06Vd3oa68E+vddf/UBOs=
github.com/mjl-/sconf v0.0.7 h1:bdBcSFZCDFMm/UdBsgNCsjkYmKrSgYwp7rAOoufwHe4=
github.com/mjl-/sconf v0.0.7/go.mod h1:uF8OdWtLT8La3i4ln176i1pB0ps9pXGCaABEU55ZkE0=
github.com/mjl-/sherpa v0.6.7 h1:C5F8XQdV5nCuS4fvB+ye/ziUQrajEhOoj/t2w5T14BY=
github.com/mjl-/sherpa v0.6.7/go.mod h1:dSpAOdgpwdqQZ72O4n3EHo/tR68eKyan8tYYraUMPNc=
github.com/mjl-/sherpadoc v0.0.0-20190505200843-c0a7f43f5f1d/go.mod h1:5khTKxoKKNXcB8bkVUO6GlzC7PFtMmkHq578lPbmnok=
github.com/mjl-/sherpadoc v0.0.12 h1:6hVe2Z0DnwjC0bfuOwfz8ov7JTCUU49cEaj7h22NiPk=
github.com/mjl-/sherpadoc v0.0.12/go.mod h1:vh5zcsk3j/Tvm725EY+unTZb3EZcZcpiEQzrODSa6+I=
github.com/mjl-/sherpadoc v0.0.16 h1:BdlFNXfnTaA7qO54kof4xpNFJxYBTY0cIObRk7QAP6M=
github.com/mjl-/sherpadoc v0.0.16/go.mod h1:vh5zcsk3j/Tvm725EY+unTZb3EZcZcpiEQzrODSa6+I=
github.com/mjl-/sherpaprom v0.0.2 h1:1dlbkScsNafM5jURI44uiWrZMSwfZtcOFEEq7vx2C1Y=
github.com/mjl-/sherpaprom v0.0.2/go.mod h1:cl5nMNOvqhzMiQJ2FzccQ9ReivjHXe53JhOVkPfSvw4=
github.com/mjl-/sherpats v0.0.5 h1:XJBorAxUY/C8IBjCmu741353Ex7CFXrb53NDGCrBu74=
github.com/mjl-/sherpats v0.0.5/go.mod h1:MoNZJtLmu8oCZ4Ocv5vZksENN4pp6/SJMlg9uTII4KA=
github.com/mjl-/sherpats v0.0.6 h1:2lSoJbb+jkjLOdlvoMxItq0QQrrnkH+rnm3PMRfpbmA=
github.com/mjl-/sherpats v0.0.6/go.mod h1:MoNZJtLmu8oCZ4Ocv5vZksENN4pp6/SJMlg9uTII4KA=
github.com/mjl-/xfmt v0.0.2 h1:6dLgd6U3bmDJKtTxsaSYYyMaORoO4hKBAJo4XKkPRko=
github.com/mjl-/xfmt v0.0.2/go.mod h1:DIEOLmETMQHHr4OgwPG7iC37rDiN9MaZIZxNm5hBtL8=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@ -60,43 +63,45 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R
github.com/prometheus/procfs v0.0.0-20190503130316-740c07785007/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA=
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM=
golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -107,5 +112,6 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

View File

@ -3,16 +3,16 @@ package http
import (
"encoding/xml"
"fmt"
"log/slog"
"net/http"
"strings"
"golang.org/x/exp/slog"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"rsc.io/qr"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/smtp"
)
@ -65,19 +65,35 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) {
email := r.FormValue("emailaddress")
log.Debug("autoconfig request", slog.String("email", email))
var domain dns.Domain
if email == "" {
email = "%EMAILADDRESS%"
// Declare this here rather than using := to avoid shadowing domain from
// the outer scope.
var err error
domain, err = dns.ParseDomain(r.Host)
if err != nil {
http.Error(w, fmt.Sprintf("400 - bad request - invalid domain: %s", r.Host), http.StatusBadRequest)
return
}
domain.ASCII = strings.TrimPrefix(domain.ASCII, "autoconfig.")
domain.Unicode = strings.TrimPrefix(domain.Unicode, "autoconfig.")
} else {
addr, err := smtp.ParseAddress(email)
if err != nil {
http.Error(w, "400 - bad request - invalid parameter emailaddress", http.StatusBadRequest)
return
}
domain = addr.Domain
}
socketType := func(tlsMode mox.TLSMode) (string, error) {
socketType := func(tlsMode admin.TLSMode) (string, error) {
switch tlsMode {
case mox.TLSModeImmediate:
case admin.TLSModeImmediate:
return "SSL", nil
case mox.TLSModeSTARTTLS:
case admin.TLSModeSTARTTLS:
return "STARTTLS", nil
case mox.TLSModeNone:
case admin.TLSModeNone:
return "plain", nil
default:
return "", fmt.Errorf("unknown tls mode %v", tlsMode)
@ -85,7 +101,7 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) {
}
var imapTLS, submissionTLS string
config, err := mox.ClientConfigDomain(addr.Domain)
config, err := admin.ClientConfigDomain(domain)
if err == nil {
imapTLS, err = socketType(config.IMAP.TLSMode)
}
@ -100,37 +116,67 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) {
// Thunderbird doesn't seem to allow U-labels, always return ASCII names.
var resp autoconfigResponse
resp.Version = "1.1"
resp.EmailProvider.ID = addr.Domain.ASCII
resp.EmailProvider.Domain = addr.Domain.ASCII
resp.EmailProvider.ID = domain.ASCII
resp.EmailProvider.Domain = domain.ASCII
resp.EmailProvider.DisplayName = email
resp.EmailProvider.DisplayShortName = addr.Domain.ASCII
resp.EmailProvider.DisplayShortName = domain.ASCII
// todo: specify SCRAM-SHA-256 once thunderbird and autoconfig supports it. or perhaps that will fall under "password-encrypted" by then.
// todo: let user configure they prefer or require tls client auth and specify "TLS-client-cert"
resp.EmailProvider.IncomingServer.Type = "imap"
resp.EmailProvider.IncomingServer.Hostname = config.IMAP.Host.ASCII
resp.EmailProvider.IncomingServer.Port = config.IMAP.Port
resp.EmailProvider.IncomingServer.SocketType = imapTLS
resp.EmailProvider.IncomingServer.Username = email
resp.EmailProvider.IncomingServer.Authentication = "password-encrypted"
incoming := incomingServer{
"imap",
config.IMAP.Host.ASCII,
config.IMAP.Port,
imapTLS,
email,
"password-encrypted",
}
resp.EmailProvider.IncomingServers = append(resp.EmailProvider.IncomingServers, incoming)
if config.IMAP.EnabledOnHTTPS {
tlsMode, _ := socketType(admin.TLSModeImmediate)
incomingALPN := incomingServer{
"imap",
config.IMAP.Host.ASCII,
443,
tlsMode,
email,
"password-encrypted",
}
resp.EmailProvider.IncomingServers = append(resp.EmailProvider.IncomingServers, incomingALPN)
}
resp.EmailProvider.OutgoingServer.Type = "smtp"
resp.EmailProvider.OutgoingServer.Hostname = config.Submission.Host.ASCII
resp.EmailProvider.OutgoingServer.Port = config.Submission.Port
resp.EmailProvider.OutgoingServer.SocketType = submissionTLS
resp.EmailProvider.OutgoingServer.Username = email
resp.EmailProvider.OutgoingServer.Authentication = "password-encrypted"
outgoing := outgoingServer{
"smtp",
config.Submission.Host.ASCII,
config.Submission.Port,
submissionTLS,
email,
"password-encrypted",
}
resp.EmailProvider.OutgoingServers = append(resp.EmailProvider.OutgoingServers, outgoing)
if config.Submission.EnabledOnHTTPS {
tlsMode, _ := socketType(admin.TLSModeImmediate)
outgoingALPN := outgoingServer{
"smtp",
config.Submission.Host.ASCII,
443,
tlsMode,
email,
"password-encrypted",
}
resp.EmailProvider.OutgoingServers = append(resp.EmailProvider.OutgoingServers, outgoingALPN)
}
// todo: should we put the email address in the URL?
resp.ClientConfigUpdate.URL = fmt.Sprintf("https://autoconfig.%s/mail/config-v1.1.xml", addr.Domain.ASCII)
resp.ClientConfigUpdate.URL = fmt.Sprintf("https://autoconfig.%s/mail/config-v1.1.xml", domain.ASCII)
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
enc := xml.NewEncoder(w)
enc.Indent("", "\t")
fmt.Fprint(w, xml.Header)
if err := enc.Encode(resp); err != nil {
log.Errorx("marshal autoconfig response", err)
}
err = enc.Encode(resp)
log.Check(err, "write autoconfig xml response")
}
// Autodiscover from Microsoft, also used by Thunderbird.
@ -171,13 +217,13 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
}
// tlsmode returns the "ssl" and "encryption" fields.
tlsmode := func(tlsMode mox.TLSMode) (string, string, error) {
tlsmode := func(tlsMode admin.TLSMode) (string, string, error) {
switch tlsMode {
case mox.TLSModeImmediate:
case admin.TLSModeImmediate:
return "on", "TLS", nil
case mox.TLSModeSTARTTLS:
case admin.TLSModeSTARTTLS:
return "on", "", nil
case mox.TLSModeNone:
case admin.TLSModeNone:
return "off", "", nil
default:
return "", "", fmt.Errorf("unknown tls mode %v", tlsMode)
@ -186,7 +232,7 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
var imapSSL, imapEncryption string
var submissionSSL, submissionEncryption string
config, err := mox.ClientConfigDomain(addr.Domain)
config, err := admin.ClientConfigDomain(addr.Domain)
if err == nil {
imapSSL, imapEncryption, err = tlsmode(config.IMAP.TLSMode)
}
@ -209,6 +255,8 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
// todo: let user configure they prefer or require tls client auth and add "AuthPackage" with value "certificate" to Protocol? see https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/21fd2dd5-c4ee-485b-94fb-e7db5da93726
resp := autodiscoverResponse{}
resp.XMLName.Local = "Autodiscover"
resp.XMLName.Space = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006"
@ -243,9 +291,8 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
enc := xml.NewEncoder(w)
enc.Indent("", "\t")
fmt.Fprint(w, xml.Header)
if err := enc.Encode(resp); err != nil {
log.Errorx("marshal autodiscover response", err)
}
err = enc.Encode(resp)
log.Check(err, "marshal autodiscover xml response")
}
// Thunderbird requests these URLs for autoconfig/autodiscover:
@ -253,6 +300,22 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
// https://autodiscover.example.org/autodiscover/autodiscover.xml
// https://example.org/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=user%40example.org
// https://example.org/autodiscover/autodiscover.xml
type incomingServer struct {
Type string `xml:"type,attr"`
Hostname string `xml:"hostname"`
Port int `xml:"port"`
SocketType string `xml:"socketType"`
Username string `xml:"username"`
Authentication string `xml:"authentication"`
}
type outgoingServer struct {
Type string `xml:"type,attr"`
Hostname string `xml:"hostname"`
Port int `xml:"port"`
SocketType string `xml:"socketType"`
Username string `xml:"username"`
Authentication string `xml:"authentication"`
}
type autoconfigResponse struct {
XMLName xml.Name `xml:"clientConfig"`
Version string `xml:"version,attr"`
@ -263,23 +326,8 @@ type autoconfigResponse struct {
DisplayName string `xml:"displayName"`
DisplayShortName string `xml:"displayShortName"`
IncomingServer struct {
Type string `xml:"type,attr"`
Hostname string `xml:"hostname"`
Port int `xml:"port"`
SocketType string `xml:"socketType"`
Username string `xml:"username"`
Authentication string `xml:"authentication"`
} `xml:"incomingServer"`
OutgoingServer struct {
Type string `xml:"type,attr"`
Hostname string `xml:"hostname"`
Port int `xml:"port"`
SocketType string `xml:"socketType"`
Username string `xml:"username"`
Authentication string `xml:"authentication"`
} `xml:"outgoingServer"`
IncomingServers []incomingServer `xml:"incomingServer"`
OutgoingServers []outgoingServer `xml:"outgoingServer"`
} `xml:"emailProvider"`
ClientConfigUpdate struct {
@ -325,6 +373,8 @@ type autodiscoverProtocol struct {
// Serve a .mobileconfig file. This endpoint is not a standard place where Apple
// devices look. We point to it from the account page.
func mobileconfigHandle(w http.ResponseWriter, r *http.Request) {
log := pkglog.WithContext(r.Context())
if r.Method != "GET" {
http.Error(w, "405 - method not allowed - get required", http.StatusMethodNotAllowed)
return
@ -350,12 +400,15 @@ func mobileconfigHandle(w http.ResponseWriter, r *http.Request) {
filename = strings.ReplaceAll(filename, "@", "-at-")
filename = "email-account-" + filename + ".mobileconfig"
h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Write(buf)
_, err = w.Write(buf)
log.Check(err, "writing mobileconfig response")
}
// Serve a png file with qrcode with the link to the .mobileconfig file, should be
// helpful for mobile devices.
func mobileconfigQRCodeHandle(w http.ResponseWriter, r *http.Request) {
log := pkglog.WithContext(r.Context())
if r.Method != "GET" {
http.Error(w, "405 - method not allowed - get required", http.StatusMethodNotAllowed)
return
@ -382,5 +435,6 @@ func mobileconfigQRCodeHandle(w http.ResponseWriter, r *http.Request) {
}
h := w.Header()
h.Set("Content-Type", "image/png")
w.Write(code.PNG())
_, err = w.Write(code.PNG())
log.Check(err, "writing mobileconfig qr code")
}

BIN
http/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 B

View File

@ -7,6 +7,7 @@ import (
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
@ -16,8 +17,6 @@ import (
"sync"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/mlog"
)

17
http/main_test.go Normal file
View File

@ -0,0 +1,17 @@
package http
import (
"fmt"
"os"
"testing"
"github.com/mjl-/mox/metrics"
)
func TestMain(m *testing.M) {
m.Run()
if metrics.Panics.Load() > 0 {
fmt.Println("unhandled panics encountered")
os.Exit(2)
}
}

View File

@ -6,13 +6,11 @@ import (
"crypto/sha256"
"encoding/xml"
"fmt"
"sort"
"maps"
"slices"
"strings"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/smtp"
)
@ -39,8 +37,7 @@ func (m dict) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if err := e.EncodeToken(xml.StartElement{Name: xml.Name{Local: "dict"}}); err != nil {
return err
}
l := maps.Keys(m)
sort.Strings(l)
l := slices.Sorted(maps.Keys(m))
for _, k := range l {
tokens := []xml.Token{
xml.StartElement{Name: xml.Name{Local: "key"}},
@ -64,7 +61,7 @@ func (m dict) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
case int:
tokens = []xml.Token{
xml.StartElement{Name: xml.Name{Local: "integer"}},
xml.CharData([]byte(fmt.Sprintf("%d", v))),
xml.CharData(fmt.Appendf(nil, "%d", v)),
xml.EndElement{Name: xml.Name{Local: "integer"}},
}
case bool:
@ -122,7 +119,7 @@ func MobileConfig(addresses []string, fullName string) ([]byte, error) {
return nil, fmt.Errorf("parsing address: %v", err)
}
config, err := mox.ClientConfigDomain(addr.Domain)
config, err := admin.ClientConfigDomain(addr.Domain)
if err != nil {
return nil, fmt.Errorf("getting config for domain: %v", err)
}
@ -175,12 +172,12 @@ func MobileConfig(addresses []string, fullName string) ([]byte, error) {
"IncomingMailServerUsername": addresses[0],
"IncomingMailServerHostName": config.IMAP.Host.ASCII,
"IncomingMailServerPortNumber": config.IMAP.Port,
"IncomingMailServerUseSSL": config.IMAP.TLSMode == mox.TLSModeImmediate,
"IncomingMailServerUseSSL": config.IMAP.TLSMode == admin.TLSModeImmediate,
"OutgoingMailServerAuthentication": "EmailAuthCRAMMD5", // SCRAM not an option at time of writing...
"OutgoingMailServerHostName": config.Submission.Host.ASCII,
"OutgoingMailServerPortNumber": config.Submission.Port,
"OutgoingMailServerUsername": addresses[0],
"OutgoingMailServerUseSSL": config.Submission.TLSMode == mox.TLSModeImmediate,
"OutgoingMailServerUseSSL": config.Submission.TLSMode == admin.TLSModeImmediate,
"OutgoingPasswordSameAsIncomingPassword": true,
"PayloadIdentifier": reverseAddr + ".email.account",
"PayloadType": "com.apple.mail.managed",

View File

@ -1,13 +1,12 @@
package http
import (
"log/slog"
"net"
"net/http"
"strings"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
@ -44,9 +43,9 @@ func mtastsPolicyHandle(w http.ResponseWriter, r *http.Request) {
return
}
var mxs []mtasts.STSMX
var mxs []mtasts.MX
for _, s := range sts.MX {
var mx mtasts.STSMX
var mx mtasts.MX
if strings.HasPrefix(s, "*.") {
mx.Wildcard = true
s = s[2:]
@ -61,7 +60,7 @@ func mtastsPolicyHandle(w http.ResponseWriter, r *http.Request) {
mxs = append(mxs, mx)
}
if len(mxs) == 0 {
mxs = []mtasts.STSMX{{Domain: mox.Conf.Static.HostnameDomain}}
mxs = []mtasts.MX{{Domain: mox.Conf.Static.HostnameDomain}}
}
policy := mtasts.Policy{

View File

@ -10,18 +10,21 @@ import (
"fmt"
"io"
golog "log"
"log/slog"
"maps"
"net"
"net/http"
"os"
"path"
"slices"
"sort"
"strings"
"time"
_ "embed"
_ "net/http/pprof"
"golang.org/x/exp/maps"
"golang.org/x/exp/slog"
"golang.org/x/net/http2"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@ -30,11 +33,14 @@ import (
"github.com/mjl-/mox/autotls"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/imapserver"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/ratelimit"
"github.com/mjl-/mox/smtpserver"
"github.com/mjl-/mox/webaccount"
"github.com/mjl-/mox/webadmin"
"github.com/mjl-/mox/webapisrv"
"github.com/mjl-/mox/webmail"
)
@ -73,6 +79,29 @@ var (
)
)
// We serve a favicon when webaccount/webmail/webadmin/webapi for account-related
// domains. They are configured as "service handler", which have a lower priority
// than web handler. Admins can configure a custom /favicon.ico route to override
// the builtin favicon. In the future, we may want to make it easier to customize
// the favicon, possibly per client settings domain.
//
//go:embed favicon.ico
var faviconIco string
var faviconModTime = time.Now()
func init() {
p, err := os.Executable()
if err == nil {
if st, err := os.Stat(p); err == nil {
faviconModTime = st.ModTime()
}
}
}
func faviconHandle(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, "favicon.ico", faviconModTime, strings.NewReader(faviconIco))
}
type responseWriterFlusher interface {
http.ResponseWriter
http.Flusher
@ -323,7 +352,7 @@ func (w *loggingWriter) Done() {
slog.Any("remoteaddr", w.R.RemoteAddr),
slog.String("tlsinfo", tlsinfo),
slog.String("useragent", w.R.Header.Get("User-Agent")),
slog.String("referrr", w.R.Header.Get("Referrer")),
slog.String("referer", w.R.Header.Get("Referer")),
}
if w.WebsocketRequest {
attrs = append(attrs,
@ -350,37 +379,45 @@ func (w *loggingWriter) Done() {
pkglog.WithContext(w.R.Context()).Debugx("http request", err, attrs...)
}
// Set some http headers that should prevent potential abuse. Better safe than sorry.
func safeHeaders(fn http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Frame-Options", "deny")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline' data:")
h.Set("Referrer-Policy", "same-origin")
fn.ServeHTTP(w, r)
})
}
// Built-in handlers, e.g. mta-sts and autoconfig.
type pathHandler struct {
Name string // For logging/metrics.
HostMatch func(dom dns.Domain) bool // If not nil, called to see if domain of requests matches. Only called if requested host is a valid domain.
HostMatch func(host dns.IPDomain) bool // If not nil, called to see if domain of requests matches. Host can be zero value for invalid domain/ip.
Path string // Path to register, like on http.ServeMux.
Handler http.Handler
}
type serve struct {
Kinds []string // Type of handler and protocol (e.g. acme-tls-alpn-01, account-http, admin-https).
Kinds []string // Type of handler and protocol (e.g. acme-tls-alpn-01, account-http, admin-https, imap-https, smtp-https).
TLSConfig *tls.Config
PathHandlers []pathHandler // Sorted, longest first.
Webserver bool // Whether serving WebHandler. PathHandlers are always evaluated before WebHandlers.
NextProto tlsNextProtoMap // For HTTP server, when we do submission/imap with ALPN over the HTTPS port.
Favicon bool
Forwarded bool // Requests are coming from a reverse proxy, we'll use X-Forwarded-For for the IP address to ratelimit.
RateLimitDisabled bool // Don't apply ratelimiting.
// SystemHandlers are for MTA-STS, autoconfig, ACME validation. They can't be
// overridden by WebHandlers. WebHandlers are evaluated next, and the internal
// service handlers from Listeners in mox.conf (for admin, account, webmail, webapi
// interfaces) last. WebHandlers can also pass requests to the internal servers.
// This order allows admins to serve other content on domains serving the mox.conf
// internal services.
SystemHandlers []pathHandler // Sorted, longest first.
Webserver bool
ServiceHandlers []pathHandler // Sorted, longest first.
}
// Handle registers a named handler for a path and optional host. If path ends with
// a slash, it is used as prefix match, otherwise a full path match is required. If
// hostOpt is set, only requests to those host are handled by this handler.
func (s *serve) Handle(name string, hostMatch func(dns.Domain) bool, path string, fn http.Handler) {
s.PathHandlers = append(s.PathHandlers, pathHandler{name, hostMatch, path, fn})
// SystemHandle registers a named system handler for a path and optional host. If
// path ends with a slash, it is used as prefix match, otherwise a full path match
// is required. If hostOpt is set, only requests to those host are handled by this
// handler.
func (s *serve) SystemHandle(name string, hostMatch func(dns.IPDomain) bool, path string, fn http.Handler) {
s.SystemHandlers = append(s.SystemHandlers, pathHandler{name, hostMatch, path, fn})
}
// Like SystemHandle, but for internal services "admin", "account", "webmail",
// "webapi" configured in the mox.conf Listener.
func (s *serve) ServiceHandle(name string, hostMatch func(dns.IPDomain) bool, path string, fn http.Handler) {
s.ServiceHandlers = append(s.ServiceHandlers, pathHandler{name, hostMatch, path, fn})
}
var (
@ -403,13 +440,30 @@ var (
// metrics.
func (s *serve) ServeHTTP(xw http.ResponseWriter, r *http.Request) {
now := time.Now()
// Rate limiting as early as possible.
ipstr, _, err := net.SplitHostPort(r.RemoteAddr)
// Rate limiting as early as possible, if enabled.
if !s.RateLimitDisabled {
// If requests are coming from a reverse proxy, use the IP from X-Forwarded-For.
// Otherwise the remote IP for this connection.
var ipstr string
if s.Forwarded {
s := r.Header.Get("X-Forwarded-For")
ipstr = strings.TrimSpace(strings.Split(s, ",")[0])
if ipstr == "" {
pkglog.Debug("ratelimit: no ip address in X-Forwarded-For header")
}
} else {
var err error
ipstr, _, err = net.SplitHostPort(r.RemoteAddr)
if err != nil {
pkglog.Debugx("split host:port client remoteaddr", err, slog.Any("remoteaddr", r.RemoteAddr))
} else if ip := net.ParseIP(ipstr); ip == nil {
pkglog.Debug("parsing ip for client remoteaddr", slog.Any("remoteaddr", r.RemoteAddr))
} else if !limiterConnectionrate.Add(ip, now, 1) {
pkglog.Debugx("ratelimit: parsing remote address", err, slog.String("remoteaddr", r.RemoteAddr))
}
}
ip := net.ParseIP(ipstr)
if ip == nil && ipstr != "" {
pkglog.Debug("ratelimit: invalid ip", slog.String("ip", ipstr))
}
if ip != nil && !limiterConnectionrate.Add(ip, now, 1) {
method := metricHTTPMethod(r.Method)
proto := "http"
if r.TLS != nil {
@ -421,6 +475,7 @@ func (s *serve) ServeHTTP(xw http.ResponseWriter, r *http.Request) {
http.Error(xw, "429 - too many auth attempts", http.StatusTooManyRequests)
return
}
}
ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid())
r = r.WithContext(ctx)
@ -451,28 +506,44 @@ func (s *serve) ServeHTTP(xw http.ResponseWriter, r *http.Request) {
r.URL.Path += "/"
}
var dom dns.Domain
host := r.Host
nhost, _, err := net.SplitHostPort(host)
if err == nil {
host = nhost
}
// host could be an IP, some handles may match, not an error.
ipdom := dns.IPDomain{IP: net.ParseIP(host)}
if ipdom.IP == nil {
dom, domErr := dns.ParseDomain(host)
if domErr == nil {
ipdom = dns.IPDomain{Domain: dom}
}
}
for _, h := range s.PathHandlers {
if h.HostMatch != nil && (domErr != nil || !h.HostMatch(dom)) {
continue
handle := func(h pathHandler) bool {
if h.HostMatch != nil && !h.HostMatch(ipdom) {
return false
}
if r.URL.Path == h.Path || strings.HasSuffix(h.Path, "/") && strings.HasPrefix(r.URL.Path, h.Path) {
nw.Handler = h.Name
nw.Compress = true
h.Handler.ServeHTTP(nw, r)
return true
}
return false
}
for _, h := range s.SystemHandlers {
if handle(h) {
return
}
}
if s.Webserver && domErr == nil {
if WebHandle(nw, r, dom) {
if s.Webserver {
if WebHandle(nw, r, ipdom) {
return
}
}
for _, h := range s.ServiceHandlers {
if handle(h) {
return
}
}
@ -480,63 +551,138 @@ func (s *serve) ServeHTTP(xw http.ResponseWriter, r *http.Request) {
http.NotFound(nw, r)
}
func redirectToTrailingSlash(srv *serve, hostMatch func(dns.IPDomain) bool, name, path string) {
// Helpfully redirect user to version with ending slash.
if path != "/" && strings.HasSuffix(path, "/") {
handler := mox.SafeHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, path, http.StatusSeeOther)
}))
srv.ServiceHandle(name, hostMatch, strings.TrimRight(path, "/"), handler)
}
}
// Listen binds to sockets for HTTP listeners, including those required for ACME to
// generate TLS certificates. It stores the listeners so Serve can start serving them.
func Listen() {
redirectToTrailingSlash := func(srv *serve, name, path string) {
// Helpfully redirect user to version with ending slash.
if path != "/" && strings.HasSuffix(path, "/") {
handler := safeHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, path, http.StatusSeeOther)
}))
srv.Handle(name, nil, path[:len(path)-1], handler)
}
}
// Initialize listeners in deterministic order for the same potential error
// messages.
names := maps.Keys(mox.Conf.Static.Listeners)
sort.Strings(names)
names := slices.Sorted(maps.Keys(mox.Conf.Static.Listeners))
for _, name := range names {
l := mox.Conf.Static.Listeners[name]
portServe := portServes(name, l)
ports := slices.Sorted(maps.Keys(portServe))
for _, port := range ports {
srv := portServe[port]
for _, ip := range l.IPs {
listen1(ip, port, srv.TLSConfig, name, srv.Kinds, srv, srv.NextProto)
}
}
}
}
func portServes(name string, l config.Listener) map[int]*serve {
portServe := map[int]*serve{}
var ensureServe func(https bool, port int, kind string) *serve
ensureServe = func(https bool, port int, kind string) *serve {
// For system/services, we serve on host localhost too, for ssh tunnel scenario's.
localhost := dns.Domain{ASCII: "localhost"}
ldom := l.HostnameDomain
if l.Hostname == "" {
ldom = mox.Conf.Static.HostnameDomain
}
listenerHostMatch := func(host dns.IPDomain) bool {
if host.IsIP() {
return true
}
return host.Domain == ldom || host.Domain == localhost
}
accountHostMatch := func(host dns.IPDomain) bool {
if listenerHostMatch(host) {
return true
}
return mox.Conf.IsClientSettingsDomain(host.Domain)
}
var ensureServe func(https, forwarded, noRateLimiting bool, port int, kind string, favicon bool) *serve
ensureServe = func(https, forwarded, rateLimitDisabled bool, port int, kind string, favicon bool) *serve {
s := portServe[port]
if s == nil {
s = &serve{nil, nil, nil, false}
s = &serve{nil, nil, tlsNextProtoMap{}, false, false, false, nil, false, nil}
portServe[port] = s
}
s.Kinds = append(s.Kinds, kind)
if https && l.TLS.ACME != "" {
s.TLSConfig = l.TLS.ACMEConfig
} else if https {
s.TLSConfig = l.TLS.Config
if l.TLS.ACME != "" {
tlsport := config.Port(mox.Conf.Static.ACME[l.TLS.ACME].Port, 443)
ensureServe(true, tlsport, "acme-tls-alpn-01")
if favicon && !s.Favicon {
s.ServiceHandle("favicon", accountHostMatch, "/favicon.ico", mox.SafeHeaders(http.HandlerFunc(faviconHandle)))
s.Favicon = true
}
s.Forwarded = s.Forwarded || forwarded
s.RateLimitDisabled = s.RateLimitDisabled || rateLimitDisabled
// We clone TLS configs because we may modify it later on for this server, for
// ALPN. And we need copies because multiple listeners on http.Server where the
// config is used will try to modify it concurrently.
if https && l.TLS.ACME != "" {
s.TLSConfig = l.TLS.ACMEConfig.Clone()
tlsport := config.Port(mox.Conf.Static.ACME[l.TLS.ACME].Port, 443)
if portServe[tlsport] == nil || !slices.Contains(portServe[tlsport].Kinds, "acme-tls-alpn-01") {
ensureServe(true, false, false, tlsport, "acme-tls-alpn-01", false)
}
} else if https {
s.TLSConfig = l.TLS.Config.Clone()
}
return s
}
if l.TLS != nil && l.TLS.ACME != "" && (l.SMTP.Enabled && !l.SMTP.NoSTARTTLS || l.Submissions.Enabled || l.IMAPS.Enabled) {
port := config.Port(mox.Conf.Static.ACME[l.TLS.ACME].Port, 443)
ensureServe(true, port, "acme-tls-alpn-01")
// If TLS with ACME is enabled on this plain HTTP port, and it hasn't been enabled
// yet, add http-01 validation mechanism handler to server.
ensureACMEHTTP01 := func(srv *serve) {
if l.TLS != nil && l.TLS.ACME != "" && !slices.Contains(srv.Kinds, "acme-http-01") {
m := mox.Conf.Static.ACME[l.TLS.ACME].Manager
srv.Kinds = append(srv.Kinds, "acme-http-01")
srv.SystemHandle("acme-http-01", nil, "/.well-known/acme-challenge/", m.Manager.HTTPHandler(nil))
}
}
if l.TLS != nil && l.TLS.ACME != "" && (l.SMTP.Enabled && !l.SMTP.NoSTARTTLS || l.Submissions.Enabled || l.IMAPS.Enabled) {
port := config.Port(mox.Conf.Static.ACME[l.TLS.ACME].Port, 443)
ensureServe(true, false, false, port, "acme-tls-alpn-01", false)
}
if l.Submissions.Enabled && l.Submissions.EnabledOnHTTPS {
s := ensureServe(true, false, false, 443, "smtp-https", false)
hostname := mox.Conf.Static.HostnameDomain
if l.Hostname != "" {
hostname = l.HostnameDomain
}
maxMsgSize := l.SMTPMaxMessageSize
if maxMsgSize == 0 {
maxMsgSize = config.DefaultMaxMsgSize
}
requireTLS := !l.SMTP.NoRequireTLS
s.NextProto["smtp"] = func(_ *http.Server, conn *tls.Conn, _ http.Handler) {
smtpserver.ServeTLSConn(name, hostname, conn, s.TLSConfig, true, true, maxMsgSize, requireTLS)
}
}
if l.IMAPS.Enabled && l.IMAPS.EnabledOnHTTPS {
s := ensureServe(true, false, false, 443, "imap-https", false)
s.NextProto["imap"] = func(_ *http.Server, conn *tls.Conn, _ http.Handler) {
imapserver.ServeTLSConn(name, conn, s.TLSConfig)
}
}
if l.AccountHTTP.Enabled {
port := config.Port(l.AccountHTTP.Port, 80)
path := "/"
if l.AccountHTTP.Path != "" {
path = l.AccountHTTP.Path
}
srv := ensureServe(false, port, "account-http at "+path)
handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webaccount.Handler(path, l.AccountHTTP.Forwarded))))
srv.Handle("account", nil, path, handler)
redirectToTrailingSlash(srv, "account", path)
srv := ensureServe(false, l.AccountHTTP.Forwarded, false, port, "account-http at "+path, true)
handler := mox.SafeHeaders(http.StripPrefix(strings.TrimRight(path, "/"), http.HandlerFunc(webaccount.Handler(path, l.AccountHTTP.Forwarded))))
srv.ServiceHandle("account", accountHostMatch, path, handler)
redirectToTrailingSlash(srv, accountHostMatch, "account", path)
ensureACMEHTTP01(srv)
}
if l.AccountHTTPS.Enabled {
port := config.Port(l.AccountHTTPS.Port, 443)
@ -544,10 +690,10 @@ func Listen() {
if l.AccountHTTPS.Path != "" {
path = l.AccountHTTPS.Path
}
srv := ensureServe(true, port, "account-https at "+path)
handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webaccount.Handler(path, l.AccountHTTPS.Forwarded))))
srv.Handle("account", nil, path, handler)
redirectToTrailingSlash(srv, "account", path)
srv := ensureServe(true, l.AccountHTTPS.Forwarded, false, port, "account-https at "+path, true)
handler := mox.SafeHeaders(http.StripPrefix(strings.TrimRight(path, "/"), http.HandlerFunc(webaccount.Handler(path, l.AccountHTTPS.Forwarded))))
srv.ServiceHandle("account", accountHostMatch, path, handler)
redirectToTrailingSlash(srv, accountHostMatch, "account", path)
}
if l.AdminHTTP.Enabled {
@ -556,10 +702,11 @@ func Listen() {
if l.AdminHTTP.Path != "" {
path = l.AdminHTTP.Path
}
srv := ensureServe(false, port, "admin-http at "+path)
handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webadmin.Handler(path, l.AdminHTTP.Forwarded))))
srv.Handle("admin", nil, path, handler)
redirectToTrailingSlash(srv, "admin", path)
srv := ensureServe(false, l.AdminHTTP.Forwarded, false, port, "admin-http at "+path, true)
handler := mox.SafeHeaders(http.StripPrefix(strings.TrimRight(path, "/"), http.HandlerFunc(webadmin.Handler(path, l.AdminHTTP.Forwarded))))
srv.ServiceHandle("admin", listenerHostMatch, path, handler)
redirectToTrailingSlash(srv, listenerHostMatch, "admin", path)
ensureACMEHTTP01(srv)
}
if l.AdminHTTPS.Enabled {
port := config.Port(l.AdminHTTPS.Port, 443)
@ -567,26 +714,59 @@ func Listen() {
if l.AdminHTTPS.Path != "" {
path = l.AdminHTTPS.Path
}
srv := ensureServe(true, port, "admin-https at "+path)
handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webadmin.Handler(path, l.AdminHTTPS.Forwarded))))
srv.Handle("admin", nil, path, handler)
redirectToTrailingSlash(srv, "admin", path)
srv := ensureServe(true, l.AdminHTTPS.Forwarded, false, port, "admin-https at "+path, true)
handler := mox.SafeHeaders(http.StripPrefix(strings.TrimRight(path, "/"), http.HandlerFunc(webadmin.Handler(path, l.AdminHTTPS.Forwarded))))
srv.ServiceHandle("admin", listenerHostMatch, path, handler)
redirectToTrailingSlash(srv, listenerHostMatch, "admin", path)
}
maxMsgSize := l.SMTPMaxMessageSize
if maxMsgSize == 0 {
maxMsgSize = config.DefaultMaxMsgSize
}
if l.WebAPIHTTP.Enabled {
port := config.Port(l.WebAPIHTTP.Port, 80)
path := "/webapi/"
if l.WebAPIHTTP.Path != "" {
path = l.WebAPIHTTP.Path
}
srv := ensureServe(false, l.WebAPIHTTP.Forwarded, false, port, "webapi-http at "+path, true)
handler := mox.SafeHeaders(http.StripPrefix(strings.TrimRight(path, "/"), webapisrv.NewServer(maxMsgSize, path, l.WebAPIHTTP.Forwarded)))
srv.ServiceHandle("webapi", accountHostMatch, path, handler)
redirectToTrailingSlash(srv, accountHostMatch, "webapi", path)
ensureACMEHTTP01(srv)
}
if l.WebAPIHTTPS.Enabled {
port := config.Port(l.WebAPIHTTPS.Port, 443)
path := "/webapi/"
if l.WebAPIHTTPS.Path != "" {
path = l.WebAPIHTTPS.Path
}
srv := ensureServe(true, l.WebAPIHTTPS.Forwarded, false, port, "webapi-https at "+path, true)
handler := mox.SafeHeaders(http.StripPrefix(strings.TrimRight(path, "/"), webapisrv.NewServer(maxMsgSize, path, l.WebAPIHTTPS.Forwarded)))
srv.ServiceHandle("webapi", accountHostMatch, path, handler)
redirectToTrailingSlash(srv, accountHostMatch, "webapi", path)
}
if l.WebmailHTTP.Enabled {
port := config.Port(l.WebmailHTTP.Port, 80)
path := "/webmail/"
if l.WebmailHTTP.Path != "" {
path = l.WebmailHTTP.Path
}
srv := ensureServe(false, port, "webmail-http at "+path)
handler := http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webmail.Handler(maxMsgSize, path, l.WebmailHTTP.Forwarded)))
srv.Handle("webmail", nil, path, handler)
redirectToTrailingSlash(srv, "webmail", path)
srv := ensureServe(false, l.WebmailHTTP.Forwarded, false, port, "webmail-http at "+path, true)
var accountPath string
if l.AccountHTTP.Enabled {
accountPath = "/"
if l.AccountHTTP.Path != "" {
accountPath = l.AccountHTTP.Path
}
}
handler := http.StripPrefix(strings.TrimRight(path, "/"), http.HandlerFunc(webmail.Handler(maxMsgSize, path, l.WebmailHTTP.Forwarded, accountPath)))
srv.ServiceHandle("webmail", accountHostMatch, path, handler)
redirectToTrailingSlash(srv, accountHostMatch, "webmail", path)
ensureACMEHTTP01(srv)
}
if l.WebmailHTTPS.Enabled {
port := config.Port(l.WebmailHTTPS.Port, 443)
@ -594,17 +774,24 @@ func Listen() {
if l.WebmailHTTPS.Path != "" {
path = l.WebmailHTTPS.Path
}
srv := ensureServe(true, port, "webmail-https at "+path)
handler := http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webmail.Handler(maxMsgSize, path, l.WebmailHTTPS.Forwarded)))
srv.Handle("webmail", nil, path, handler)
redirectToTrailingSlash(srv, "webmail", path)
srv := ensureServe(true, l.WebmailHTTPS.Forwarded, false, port, "webmail-https at "+path, true)
var accountPath string
if l.AccountHTTPS.Enabled {
accountPath = "/"
if l.AccountHTTPS.Path != "" {
accountPath = l.AccountHTTPS.Path
}
}
handler := http.StripPrefix(strings.TrimRight(path, "/"), http.HandlerFunc(webmail.Handler(maxMsgSize, path, l.WebmailHTTPS.Forwarded, accountPath)))
srv.ServiceHandle("webmail", accountHostMatch, path, handler)
redirectToTrailingSlash(srv, accountHostMatch, "webmail", path)
}
if l.MetricsHTTP.Enabled {
port := config.Port(l.MetricsHTTP.Port, 8010)
srv := ensureServe(false, port, "metrics-http")
srv.Handle("metrics", nil, "/metrics", safeHeaders(promhttp.Handler()))
srv.Handle("metrics", nil, "/", safeHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := ensureServe(false, false, false, port, "metrics-http", false)
srv.SystemHandle("metrics", nil, "/metrics", mox.SafeHeaders(promhttp.Handler()))
srv.SystemHandle("metrics", nil, "/", mox.SafeHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
@ -618,8 +805,15 @@ func Listen() {
}
if l.AutoconfigHTTPS.Enabled {
port := config.Port(l.AutoconfigHTTPS.Port, 443)
srv := ensureServe(!l.AutoconfigHTTPS.NonTLS, port, "autoconfig-https")
autoconfigMatch := func(dom dns.Domain) bool {
srv := ensureServe(!l.AutoconfigHTTPS.NonTLS, false, false, port, "autoconfig-https", false)
if l.AutoconfigHTTPS.NonTLS {
ensureACMEHTTP01(srv)
}
autoconfigMatch := func(ipdom dns.IPDomain) bool {
dom := ipdom.Domain
if dom.IsZero() {
return false
}
// Thunderbird requests an autodiscovery URL at the email address domain name, so
// autoconfig prefix is optional.
if strings.HasPrefix(dom.ASCII, "autoconfig.") {
@ -631,22 +825,29 @@ func Listen() {
if dom == mox.Conf.Static.HostnameDomain || dom == mox.Conf.Static.Listeners["public"].HostnameDomain {
return true
}
_, ok := mox.Conf.Domain(dom)
return ok
dc, ok := mox.Conf.Domain(dom)
return ok && !dc.ReportsOnly
}
srv.Handle("autoconfig", autoconfigMatch, "/mail/config-v1.1.xml", safeHeaders(http.HandlerFunc(autoconfHandle)))
srv.Handle("autodiscover", autoconfigMatch, "/autodiscover/autodiscover.xml", safeHeaders(http.HandlerFunc(autodiscoverHandle)))
srv.Handle("mobileconfig", autoconfigMatch, "/profile.mobileconfig", safeHeaders(http.HandlerFunc(mobileconfigHandle)))
srv.Handle("mobileconfigqrcodepng", autoconfigMatch, "/profile.mobileconfig.qrcode.png", safeHeaders(http.HandlerFunc(mobileconfigQRCodeHandle)))
srv.SystemHandle("autoconfig", autoconfigMatch, "/mail/config-v1.1.xml", mox.SafeHeaders(http.HandlerFunc(autoconfHandle)))
srv.SystemHandle("autodiscover", autoconfigMatch, "/autodiscover/autodiscover.xml", mox.SafeHeaders(http.HandlerFunc(autodiscoverHandle)))
srv.SystemHandle("mobileconfig", autoconfigMatch, "/profile.mobileconfig", mox.SafeHeaders(http.HandlerFunc(mobileconfigHandle)))
srv.SystemHandle("mobileconfigqrcodepng", autoconfigMatch, "/profile.mobileconfig.qrcode.png", mox.SafeHeaders(http.HandlerFunc(mobileconfigQRCodeHandle)))
}
if l.MTASTSHTTPS.Enabled {
port := config.Port(l.MTASTSHTTPS.Port, 443)
srv := ensureServe(!l.MTASTSHTTPS.NonTLS, port, "mtasts-https")
mtastsMatch := func(dom dns.Domain) bool {
srv := ensureServe(!l.MTASTSHTTPS.NonTLS, false, false, port, "mtasts-https", false)
if l.MTASTSHTTPS.NonTLS {
ensureACMEHTTP01(srv)
}
mtastsMatch := func(ipdom dns.IPDomain) bool {
// todo: may want to check this against the configured domains, could in theory be just a webserver.
dom := ipdom.Domain
if dom.IsZero() {
return false
}
return strings.HasPrefix(dom.ASCII, "mta-sts.")
}
srv.Handle("mtasts", mtastsMatch, "/.well-known/mta-sts.txt", safeHeaders(http.HandlerFunc(mtastsPolicyHandle)))
srv.SystemHandle("mtasts", mtastsMatch, "/.well-known/mta-sts.txt", mox.SafeHeaders(http.HandlerFunc(mtastsPolicyHandle)))
}
if l.PprofHTTP.Enabled {
// Importing net/http/pprof registers handlers on the default serve mux.
@ -654,45 +855,44 @@ func Listen() {
if _, ok := portServe[port]; ok {
pkglog.Fatal("cannot serve pprof on same endpoint as other http services")
}
srv := &serve{[]string{"pprof-http"}, nil, nil, false}
srv := &serve{[]string{"pprof-http"}, nil, nil, false, false, false, nil, false, nil}
portServe[port] = srv
srv.Handle("pprof", nil, "/", http.DefaultServeMux)
srv.SystemHandle("pprof", nil, "/", http.DefaultServeMux)
}
if l.WebserverHTTP.Enabled {
port := config.Port(l.WebserverHTTP.Port, 80)
srv := ensureServe(false, port, "webserver-http")
srv := ensureServe(false, false, l.WebserverHTTP.RateLimitDisabled, port, "webserver-http", false)
srv.Webserver = true
ensureACMEHTTP01(srv)
}
if l.WebserverHTTPS.Enabled {
port := config.Port(l.WebserverHTTPS.Port, 443)
srv := ensureServe(true, port, "webserver-https")
srv := ensureServe(true, false, l.WebserverHTTPS.RateLimitDisabled, port, "webserver-https", false)
srv.Webserver = true
}
if l.TLS != nil && l.TLS.ACME != "" {
m := mox.Conf.Static.ACME[l.TLS.ACME].Manager
// If we are listening on port 80 for plain http, also register acme http-01
// validation handler.
if srv, ok := portServe[80]; ok && srv.TLSConfig == nil {
srv.Kinds = append(srv.Kinds, "acme-http-01")
srv.Handle("acme-http-01", nil, "/.well-known/acme-challenge/", m.Manager.HTTPHandler(nil))
if ensureManagerHosts[m] == nil {
ensureManagerHosts[m] = map[dns.Domain]struct{}{}
}
hosts := ensureManagerHosts[m]
hosts[mox.Conf.Static.HostnameDomain] = struct{}{}
hosts := map[dns.Domain]struct{}{
mox.Conf.Static.HostnameDomain: {},
}
if l.HostnameDomain.ASCII != "" {
hosts[l.HostnameDomain] = struct{}{}
}
// All domains are served on all listeners. Gather autoconfig hostnames to ensure
// presence of TLS certificates for.
// presence of TLS certificates. Fetching a certificate on-demand may be too slow
// for the timeouts of clients doing autoconfig.
if l.AutoconfigHTTPS.Enabled && !l.AutoconfigHTTPS.NonTLS {
for _, name := range mox.Conf.Domains() {
if dom, err := dns.ParseDomain(name); err != nil {
pkglog.Errorx("parsing domain from config", err)
} else if d, _ := mox.Conf.Domain(dom); d.DMARC != nil && d.DMARC.Domain != "" && d.DMARC.DNSDomain != dom {
// Do not gather autoconfig name if this domain is configured to process reports
// for domains hosted elsewhere.
} else if d, _ := mox.Conf.Domain(dom); d.ReportsOnly || d.Disabled {
// Do not gather autoconfig name if we aren't accepting email for this domain or when it is disabled.
continue
}
@ -703,17 +903,25 @@ func Listen() {
hosts[autoconfdom] = struct{}{}
}
}
ensureManagerHosts[m] = hosts
}
}
ports := maps.Keys(portServe)
sort.Ints(ports)
for _, port := range ports {
srv := portServe[port]
sort.Slice(srv.PathHandlers, func(i, j int) bool {
a := srv.PathHandlers[i].Path
b := srv.PathHandlers[j].Path
if s := portServe[443]; s != nil && s.TLSConfig != nil && len(s.NextProto) > 0 {
s.TLSConfig.NextProtos = append(s.TLSConfig.NextProtos, slices.Collect(maps.Keys(s.NextProto))...)
}
for _, srv := range portServe {
sortPathHandlers(srv.SystemHandlers)
sortPathHandlers(srv.ServiceHandlers)
}
return portServe
}
func sortPathHandlers(l []pathHandler) {
sort.Slice(l, func(i, j int) bool {
a := l[i].Path
b := l[j].Path
if len(a) == len(b) {
// For consistent order.
return a < b
@ -721,11 +929,6 @@ func Listen() {
// Longest paths first.
return len(a) > len(b)
})
for _, ip := range l.IPs {
listen1(ip, port, srv.TLSConfig, name, srv.Kinds, srv)
}
}
}
}
// functions to be launched in goroutine that will serve on a listener.
@ -738,8 +941,10 @@ var servers []func()
// the certificate to be given during the first https connection.
var ensureManagerHosts = map[*autotls.Manager]map[dns.Domain]struct{}{}
type tlsNextProtoMap = map[string]func(*http.Server, *tls.Conn, http.Handler)
// listen prepares a listener, and adds it to "servers", to be launched (if not running as root) through Serve.
func listen1(ip string, port int, tlsConfig *tls.Config, name string, kinds []string, handler http.Handler) {
func listen1(ip string, port int, tlsConfig *tls.Config, name string, kinds []string, handler http.Handler, nextProto tlsNextProtoMap) {
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
var protocol string
@ -778,6 +983,15 @@ func listen1(ip string, port int, tlsConfig *tls.Config, name string, kinds []st
ReadHeaderTimeout: 30 * time.Second,
IdleTimeout: 65 * time.Second, // Chrome closes connections after 60 seconds, firefox after 115 seconds.
ErrorLog: golog.New(mlog.LogWriter(pkglog.With(slog.String("pkg", "net/http")), slog.LevelInfo, protocol+" error"), "", 0),
TLSNextProto: nextProto,
}
// By default, the Go 1.6 and above http.Server includes support for HTTP2.
// However, HTTP2 is negotiated via ALPN. Because we are configuring
// TLSNextProto above, we have to explicitly enable HTTP2 by importing http2
// and calling ConfigureServer.
err = http2.ConfigureServer(server, nil)
if err != nil {
pkglog.Fatalx("https: unable to configure http2", err)
}
serve := func() {
err := server.Serve(ln)

View File

@ -6,10 +6,8 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
)
@ -19,20 +17,8 @@ func TestServeHTTP(t *testing.T) {
mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
mox.MustLoadConfig(true, false)
srv := &serve{
PathHandlers: []pathHandler{
{
HostMatch: func(dom dns.Domain) bool {
return strings.HasPrefix(dom.ASCII, "mta-sts.")
},
Path: "/.well-known/mta-sts.txt",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("mta-sts!"))
}),
},
},
Webserver: true,
}
portSrvs := portServes("local", mox.Conf.Static.Listeners["local"])
srv := portSrvs[80]
test := func(method, target string, expCode int, expContent string, expHeaders map[string]string) {
t.Helper()
@ -43,22 +29,22 @@ func TestServeHTTP(t *testing.T) {
srv.ServeHTTP(rw, req)
resp := rw.Result()
if resp.StatusCode != expCode {
t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, expCode)
t.Errorf("got statuscode %d, expected %d", resp.StatusCode, expCode)
}
if expContent != "" {
s := rw.Body.String()
if s != expContent {
t.Fatalf("got response data %q, expected %q", s, expContent)
t.Errorf("got response data %q, expected %q", s, expContent)
}
}
for k, v := range expHeaders {
if xv := resp.Header.Get(k); xv != v {
t.Fatalf("got %q for header %q, expected %q", xv, k, v)
t.Errorf("got %q for header %q, expected %q", xv, k, v)
}
}
}
test("GET", "http://mta-sts.mox.example/.well-known/mta-sts.txt", http.StatusOK, "mta-sts!", nil)
test("GET", "http://mta-sts.mox.example/.well-known/mta-sts.txt", http.StatusOK, "version: STSv1\nmode: enforce\nmax_age: 86400\nmx: mox.example\n", nil)
test("GET", "http://mox.example/.well-known/mta-sts.txt", http.StatusNotFound, "", nil) // mta-sts endpoint not in this domain.
test("GET", "http://mta-sts.mox.example/static/", http.StatusNotFound, "", nil) // static not served on this domain.
test("GET", "http://mta-sts.mox.example/other", http.StatusNotFound, "", nil)
@ -66,4 +52,24 @@ func TestServeHTTP(t *testing.T) {
test("GET", "http://mox.example/static/index.html", http.StatusOK, "html\n", map[string]string{"X-Test": "mox"})
test("GET", "http://mox.example/static/dir/", http.StatusOK, "", map[string]string{"X-Test": "mox"}) // Dir listing.
test("GET", "http://mox.example/other", http.StatusNotFound, "", nil)
// Webmail on IP, localhost, mail host, clientsettingsdomain, not others.
test("GET", "http://127.0.0.1/webmail/", http.StatusOK, "", nil)
test("GET", "http://localhost/webmail/", http.StatusOK, "", nil)
test("GET", "http://mox.example/webmail/", http.StatusOK, "", nil)
test("GET", "http://mail.mox.example/webmail/", http.StatusOK, "", nil)
test("GET", "http://mail.other.example/webmail/", http.StatusNotFound, "", nil)
test("GET", "http://remotehost/webmail/", http.StatusNotFound, "", nil)
// admin on IP, localhost, mail host, not clientsettingsdomain.
test("GET", "http://127.0.0.1/admin/", http.StatusOK, "", nil)
test("GET", "http://localhost/admin/", http.StatusOK, "", nil)
test("GET", "http://mox.example/admin/", http.StatusPermanentRedirect, "", nil) // Override by WebHandler.
test("GET", "http://mail.mox.example/admin/", http.StatusNotFound, "", nil)
// account is off.
test("GET", "http://127.0.0.1/", http.StatusNotFound, "", nil)
test("GET", "http://localhost/", http.StatusNotFound, "", nil)
test("GET", "http://mox.example/", http.StatusNotFound, "", nil)
test("GET", "http://mail.mox.example/", http.StatusNotFound, "", nil)
}

View File

@ -13,6 +13,7 @@ import (
"io"
"io/fs"
golog "log"
"log/slog"
"net"
"net/http"
"net/http/httputil"
@ -25,13 +26,10 @@ import (
"syscall"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxio"
)
func recvid(r *http.Request) string {
@ -47,11 +45,13 @@ func recvid(r *http.Request) string {
// WebHandle runs after the built-in handlers for mta-sts, autoconfig, etc.
// If no handler matched, false is returned.
// WebHandle sets w.Name to that of the matching handler.
func WebHandle(w *loggingWriter, r *http.Request, host dns.Domain) (handled bool) {
redirects, handlers := mox.Conf.WebServer()
func WebHandle(w *loggingWriter, r *http.Request, host dns.IPDomain) (handled bool) {
conf := mox.Conf.DynamicConfig()
redirects := conf.WebDNSDomainRedirects
handlers := conf.WebHandlers
for from, to := range redirects {
if host != from {
if host.Domain != from {
continue
}
u := r.URL
@ -63,7 +63,7 @@ func WebHandle(w *loggingWriter, r *http.Request, host dns.Domain) (handled bool
}
for _, h := range handlers {
if host != h.DNSDomain {
if host.Domain != h.DNSDomain {
continue
}
loc := h.Path.FindStringIndex(r.URL.Path)
@ -98,6 +98,10 @@ func WebHandle(w *loggingWriter, r *http.Request, host dns.Domain) (handled bool
w.Handler = h.Name
return true
}
if h.WebInternal != nil && HandleInternal(h.WebInternal, w, r) {
w.Handler = h.Name
return true
}
}
w.Compress = false
return false
@ -210,12 +214,21 @@ func HandleStatic(h *config.WebStatic, compress bool, w http.ResponseWriter, r *
}
http.NotFound(w, r)
return true
} else if errors.Is(err, syscall.ENAMETOOLONG) {
http.NotFound(w, r)
return true
} else if os.IsPermission(err) {
// If we tried opening a directory, we may not have permission to read it, but
// still access files inside it (execute bit), such as index.html. So try to serve it.
index, err := os.Open(filepath.Join(fspath, "index.html"))
if err == nil {
defer index.Close()
if err != nil {
http.Error(w, "403 - permission denied", http.StatusForbidden)
return true
}
defer func() {
err := index.Close()
log().Check(err, "closing index file for serving")
}()
var ifi os.FileInfo
ifi, err = index.Stat()
if err != nil {
@ -227,14 +240,15 @@ func HandleStatic(h *config.WebStatic, compress bool, w http.ResponseWriter, r *
serveFile("index.html", ifi, index)
return true
}
http.Error(w, "403 - permission denied", http.StatusForbidden)
return true
}
log().Errorx("open file for static file serving", err, slog.Any("url", r.URL), slog.String("fspath", fspath))
http.Error(w, "500 - internal server error"+recvid(r), http.StatusInternalServerError)
return true
}
defer f.Close()
defer func() {
if err := f.Close(); err != nil {
log().Check(err, "closing file for static file serving")
}
}()
fi, err := f.Stat()
if err != nil {
@ -266,7 +280,12 @@ func HandleStatic(h *config.WebStatic, compress bool, w http.ResponseWriter, r *
http.Error(w, "403 - permission denied", http.StatusForbidden)
return true
} else if err == nil {
defer index.Close()
defer func() {
if err := index.Close(); err != nil {
log().Check(err, "closing index file for serving")
}
}()
var ifi os.FileInfo
ifi, err = index.Stat()
if err == nil {
@ -333,8 +352,8 @@ func HandleStatic(h *config.WebStatic, compress bool, w http.ResponseWriter, r *
}
}
err = lsTemplate.Execute(w, map[string]any{"Files": files})
if err != nil && !moxio.IsClosed(err) {
log().Errorx("executing directory listing template", err)
if err != nil {
log().Check(err, "executing directory listing template")
}
return true
}
@ -395,6 +414,12 @@ func HandleRedirect(h *config.WebRedirect, w http.ResponseWriter, r *http.Reques
return true
}
// HandleInternal passes the request to an internal service.
func HandleInternal(h *config.WebInternal, w http.ResponseWriter, r *http.Request) (handled bool) {
h.Handler.ServeHTTP(w, r)
return true
}
// HandleForward handles a request by forwarding it to another webserver and
// passing the response on. I.e. a reverse proxy. It handles websocket
// connections by monitoring the websocket handshake and then just passing along the
@ -581,7 +606,9 @@ func forwardWebsocket(h *config.WebForward, w http.ResponseWriter, r *http.Reque
}
defer func() {
if beconn != nil {
beconn.Close()
if err := beconn.Close(); err != nil {
log().Check(err, "closing backend websocket connection")
}
}
}()
@ -597,7 +624,9 @@ func forwardWebsocket(h *config.WebForward, w http.ResponseWriter, r *http.Reque
}
defer func() {
if cconn != nil {
cconn.Close()
if err := cconn.Close(); err != nil {
log().Check(err, "closing client websocket connection")
}
}
}()
@ -650,8 +679,12 @@ func forwardWebsocket(h *config.WebForward, w http.ResponseWriter, r *http.Reque
// connection whose closing was already announced with a websocket frame.
lw.error(<-errc)
// Close connections so other goroutine stops as well.
cconn.Close()
beconn.Close()
if err := cconn.Close(); err != nil {
log().Check(err, "closing client websocket connection")
}
if err := beconn.Close(); err != nil {
log().Check(err, "closing backend websocket connection")
}
// Wait for goroutine so it has updated the logWriter.Size*Client fields before we
// continue with logging.
<-errc
@ -704,7 +737,9 @@ func websocketTransact(ctx context.Context, targetURL *url.URL, r *http.Request)
}
defer func() {
if rerr != nil {
conn.Close()
if xerr := conn.Close(); xerr != nil {
log().Check(xerr, "cleaning up websocket connection")
}
}
}()
@ -731,7 +766,9 @@ func websocketTransact(ctx context.Context, targetURL *url.URL, r *http.Request)
}
defer func() {
if rerr != nil {
resp.Body.Close()
if xerr := resp.Body.Close(); xerr != nil {
log().Check(xerr, "closing response body after error")
}
}
}()
if err := conn.SetDeadline(time.Time{}); err != nil {

View File

@ -134,6 +134,10 @@ func TestWebserver(t *testing.T) {
test("GET", "http://mox.example/bogus", nil, http.StatusNotFound, "", nil) // path not registered.
test("GET", "http://bogus.mox.example/static/", nil, http.StatusNotFound, "", nil) // domain not registered.
test("GET", "http://mox.example/xadmin/", nil, http.StatusOK, "", nil) // internal admin service
test("GET", "http://mox.example/xaccount/", nil, http.StatusOK, "", nil) // internal account service
test("GET", "http://mox.example/xwebmail/", nil, http.StatusOK, "", nil) // internal webmail service
test("GET", "http://mox.example/xwebapi/v0/", nil, http.StatusOK, "", nil) // internal webapi service
npaths := len(staticgzcache.paths)
if npaths != 1 {
@ -335,5 +339,4 @@ func TestWebsocket(t *testing.T) {
w.WriteHeader(http.StatusSwitchingProtocols)
})
test("GET", wsreqhdrs, http.StatusSwitchingProtocols, wsresphdrs)
}

View File

@ -1,40 +1,102 @@
/*
Package imapclient provides an IMAP4 client, primarily for testing the IMAP4 server.
Package imapclient provides an IMAP4 client implementing IMAP4rev1 (RFC 3501),
IMAP4rev2 (RFC 9051) and various extensions.
Commands can be sent to the server free-form, but responses are parsed strictly.
Behaviour that may not be required by the IMAP4 specification may be expected by
this client.
Warning: Currently primarily for testing the mox IMAP4 server. Behaviour that
may not be required by the IMAP4 specification may be expected by this client.
See [Conn] for a high-level client for executing IMAP commands. Use its embedded
[Proto] for lower-level writing of commands and reading of responses.
*/
package imapclient
/*
- Try to keep the parsing method names and the types similar to the ABNF names in the RFCs.
- todo: have mode for imap4rev1 vs imap4rev2, refusing what is not allowed. we are accepting too much now.
- todo: stricter parsing. xnonspace() and xword() should be replaced by proper parsers.
*/
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"log/slog"
"net"
"reflect"
"strings"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/moxio"
)
// Conn is an IMAP connection to a server.
// Conn is an connection to an IMAP server.
//
// Method names on Conn are the names of IMAP commands. CloseMailbox, which
// executes the IMAP CLOSE command, is an exception. The Close method closes the
// connection.
//
// The methods starting with MSN are the original (old) IMAP commands. The variants
// starting with UID should almost always be used instead, if available.
//
// The methods on Conn typically return errors of type Error or Response. Error
// represents protocol and i/o level errors, including io.ErrDeadlineExceeded and
// various errors for closed connections. Response is returned as error if the IMAP
// result is NO or BAD instead of OK. The responses returned by the IMAP command
// methods can also be non-zero on errors. Callers may wish to process any untagged
// responses.
//
// The IMAP command methods defined on Conn don't interpret the untagged responses
// except for untagged CAPABILITY and untagged ENABLED responses, and the
// CAPABILITY response code. Fields CapAvailable and CapEnabled are updated when
// those untagged responses are received.
//
// Capabilities indicate which optional IMAP functionality is supported by a
// server. Capabilities are typically implicitly enabled when the client sends a
// command using syntax of an optional extension. Extensions without new syntax
// from client to server, but with new behaviour or syntax from server to client,
// the client needs to explicitly enable the capability with the ENABLE command,
// see the Enable method.
type Conn struct {
// If true, server sent a PREAUTH tag and the connection is already authenticated,
// e.g. based on TLS certificate authentication.
Preauth bool
// Capabilities available at server, from CAPABILITY command or response code.
CapAvailable []Capability
// Capabilities marked as enabled by the server, typically after an ENABLE command.
CapEnabled []Capability
// Proto provides lower-level functions for interacting with the IMAP connection,
// such as reading and writing individual lines/commands/responses.
Proto
}
// Proto provides low-level operations for writing requests and reading responses
// on an IMAP connection.
//
// To implement the IDLE command, write "IDLE" using [Proto.WriteCommandf], then
// read a line with [Proto.Readline]. If it starts with "+ ", the connection is in
// idle mode and untagged responses can be read using [Proto.ReadUntagged]. If the
// line doesn't start with "+ ", use [ParseResult] to interpret it as a response to
// IDLE, which should be a NO or BAD. To abort idle mode, write "DONE" using
// [Proto.Writelinef] and wait until a result line has been read.
type Proto struct {
// Connection, may be original TCP or TLS connection. Reads go through c.br, and
// writes through c.xbw. The "x" for the writes indicate that failed writes cause
// an i/o panic, which is either turned into a returned error, or passed on (see
// boolean panic). The reader and writer wrap a tracing reading/writer and may wrap
// flate compression.
conn net.Conn
r *bufio.Reader
panic bool
connBroken bool // If connection is broken, we won't flush (and write) again.
br *bufio.Reader
tr *moxio.TraceReader
xbw *bufio.Writer
compress bool // If compression is enabled, we must flush flateWriter and its target original bufio writer.
xflateWriter *moxio.FlateWriter
xflateBW *bufio.Writer
xtw *moxio.TraceWriter
log mlog.Log
errHandle func(err error) // If set, called for all errors. Can panic. Used for imapserver tests.
tagGen int
record bool // If true, bytes read are added to recordBuf. recorded() resets.
recordBuf []byte
LastTag string
CapAvailable map[Capability]struct{} // Capabilities available at server, from CAPABILITY command or response code.
CapEnabled map[Capability]struct{} // Capabilities enabled through ENABLE command.
lastTag string
}
// Error is a parse or other protocol error.
@ -48,22 +110,52 @@ func (e Error) Unwrap() error {
return e.err
}
// New creates a new client on conn.
//
// If xpanic is true, functions that would return an error instead panic. For parse
// errors, the resulting stack traces show typically show what was being parsed.
//
// The initial untagged greeting response is read and must be "OK".
func New(conn net.Conn, xpanic bool) (client *Conn, rerr error) {
c := Conn{
conn: conn,
r: bufio.NewReader(conn),
panic: xpanic,
CapAvailable: map[Capability]struct{}{},
CapEnabled: map[Capability]struct{}{},
// Opts has optional fields that influence behaviour of a Conn.
type Opts struct {
Logger *slog.Logger
// Error is called for IMAP-level and connection-level errors during the IMAP
// command methods on Conn, not for errors in calls on Proto. Error is allowed to
// call panic.
Error func(err error)
}
defer c.recover(&rerr)
// New initializes a new IMAP client on conn.
//
// Conn should normally be a TLS connection, typically connected to port 993 of an
// IMAP server. Alternatively, conn can be a plain TCP connection to port 143. TLS
// should be enabled on plain TCP connections with the [Conn.StartTLS] method.
//
// The initial untagged greeting response is read and must be "OK" or
// "PREAUTH". If preauth, the connection is already in authenticated state,
// typically through TLS client certificate. This is indicated in Conn.Preauth.
//
// Logging is written to opts.Logger. In particular, IMAP protocol traces are
// written with prefixes "CR: " and "CW: " (client read/write) as quoted strings at
// levels Debug-4, with authentication messages at Debug-6 and (user) data at level
// Debug-8.
func New(conn net.Conn, opts *Opts) (client *Conn, rerr error) {
c := Conn{
Proto: Proto{conn: conn},
}
var clog *slog.Logger
if opts != nil {
c.errHandle = opts.Error
clog = opts.Logger
} else {
clog = slog.Default()
}
c.log = mlog.New("imapclient", clog)
c.tr = moxio.NewTraceReader(c.log, "CR: ", &c)
c.br = bufio.NewReader(c.tr)
// Writes are buffered and write to Conn, which may panic.
c.xtw = moxio.NewTraceWriter(c.log, "CW: ", &c)
c.xbw = bufio.NewWriter(c.xtw)
defer c.recoverErr(&rerr)
tag := c.xnonspace()
if tag != "*" {
c.xerrorf("expected untagged *, got %q", tag)
@ -75,9 +167,15 @@ func New(conn net.Conn, xpanic bool) (client *Conn, rerr error) {
if x.Status != OK {
c.xerrorf("greeting, got status %q, expected OK", x.Status)
}
if x.Code != nil {
if caps, ok := x.Code.(CodeCapability); ok {
c.CapAvailable = caps
}
}
return &c, nil
case UntaggedPreauth:
c.xerrorf("greeting: unexpected preauth")
c.Preauth = true
return &c, nil
case UntaggedBye:
c.xerrorf("greeting: server sent bye")
default:
@ -86,8 +184,16 @@ func New(conn net.Conn, xpanic bool) (client *Conn, rerr error) {
panic("not reached")
}
func (c *Conn) recover(rerr *error) {
if c.panic {
func (c *Conn) recoverErr(rerr *error) {
c.recover(rerr, nil)
}
func (c *Conn) recover(rerr *error, resp *Response) {
if *rerr != nil {
if r, ok := (*rerr).(Response); ok && resp != nil {
*resp = r
}
c.errHandle(*rerr)
return
}
@ -95,30 +201,163 @@ func (c *Conn) recover(rerr *error) {
if x == nil {
return
}
err, ok := x.(Error)
if !ok {
var err error
switch e := x.(type) {
case Error:
err = e
case Response:
err = e
if resp != nil {
*resp = e
}
default:
panic(x)
}
if c.errHandle != nil {
c.errHandle(err)
}
*rerr = err
}
func (c *Conn) xerrorf(format string, args ...any) {
func (p *Proto) recover(rerr *error) {
if *rerr != nil {
return
}
x := recover()
if x == nil {
return
}
switch e := x.(type) {
case Error:
*rerr = e
default:
panic(x)
}
}
func (p *Proto) xerrorf(format string, args ...any) {
panic(Error{fmt.Errorf(format, args...)})
}
func (c *Conn) xcheckf(err error, format string, args ...any) {
func (p *Proto) xcheckf(err error, format string, args ...any) {
if err != nil {
c.xerrorf("%s: %w", fmt.Sprintf(format, args...), err)
p.xerrorf("%s: %w", fmt.Sprintf(format, args...), err)
}
}
func (c *Conn) xcheck(err error) {
func (p *Proto) xcheck(err error) {
if err != nil {
panic(err)
}
}
// TLSConnectionState returns the TLS connection state if the connection uses TLS.
// xresponse sets resp if err is a Response and resp is not nil.
func (p *Proto) xresponse(err error, resp *Response) {
if err == nil {
return
}
if r, ok := err.(Response); ok && resp != nil {
*resp = r
}
panic(err)
}
// Write writes directly to underlying connection (TCP, TLS). For internal use
// only, to implement io.Writer. Write errors do take the connection's panic mode
// into account, i.e. Write can panic.
func (p *Proto) Write(buf []byte) (n int, rerr error) {
defer p.recover(&rerr)
n, rerr = p.conn.Write(buf)
if rerr != nil {
p.connBroken = true
}
p.xcheckf(rerr, "write")
return n, nil
}
// Read reads directly from the underlying connection (TCP, TLS). For internal use
// only, to implement io.Reader.
func (p *Proto) Read(buf []byte) (n int, err error) {
return p.conn.Read(buf)
}
func (p *Proto) xflush() {
// Not writing any more when connection is broken.
if p.connBroken {
return
}
err := p.xbw.Flush()
p.xcheckf(err, "flush")
// If compression is active, we need to flush the deflate stream.
if p.compress {
err := p.xflateWriter.Flush()
p.xcheckf(err, "flush deflate")
err = p.xflateBW.Flush()
p.xcheckf(err, "flush deflate buffer")
}
}
func (p *Proto) xtraceread(level slog.Level) func() {
if p.tr == nil {
// For ParseUntagged and other parse functions.
return func() {}
}
p.tr.SetTrace(level)
return func() {
p.tr.SetTrace(mlog.LevelTrace)
}
}
func (p *Proto) xtracewrite(level slog.Level) func() {
if p.xtw == nil {
// For ParseUntagged and other parse functions.
return func() {}
}
p.xflush()
p.xtw.SetTrace(level)
return func() {
p.xflush()
p.xtw.SetTrace(mlog.LevelTrace)
}
}
// Close closes the connection, flushing and closing any compression and TLS layer.
//
// You may want to call Logout first. Closing a connection with a mailbox with
// deleted messages not yet expunged will not expunge those messages.
//
// Closing a TLS connection that is logged out, or closing a TLS connection with
// compression enabled (i.e. two layered streams), may cause spurious errors
// because the server may immediate close the underlying connection when it sees
// the connection is being closed.
func (c *Conn) Close() (rerr error) {
defer c.recoverErr(&rerr)
if c.conn == nil {
return nil
}
if !c.connBroken && c.xflateWriter != nil {
err := c.xflateWriter.Close()
c.xcheckf(err, "close deflate writer")
err = c.xflateBW.Flush()
c.xcheckf(err, "flush deflate buffer")
c.xflateWriter = nil
c.xflateBW = nil
}
err := c.conn.Close()
c.xcheckf(err, "close connection")
c.conn = nil
return
}
// TLSConnectionState returns the TLS connection state if the connection uses TLS,
// either because the conn passed to [New] was a TLS connection, or because
// [Conn.StartTLS] was called.
func (c *Conn) TLSConnectionState() *tls.ConnectionState {
if conn, ok := c.conn.(*tls.Conn); ok {
cs := conn.ConnectionState()
@ -127,177 +366,266 @@ func (c *Conn) TLSConnectionState() *tls.ConnectionState {
return nil
}
// Commandf writes a free-form IMAP command to the server.
// WriteCommandf writes a free-form IMAP command to the server. An ending \r\n is
// written too.
//
// If tag is empty, a next unique tag is assigned.
func (c *Conn) Commandf(tag string, format string, args ...any) (rerr error) {
defer c.recover(&rerr)
func (p *Proto) WriteCommandf(tag string, format string, args ...any) (rerr error) {
defer p.recover(&rerr)
if tag == "" {
tag = c.nextTag()
p.nextTag()
} else {
p.lastTag = tag
}
c.LastTag = tag
_, err := fmt.Fprintf(c.conn, "%s %s\r\n", tag, fmt.Sprintf(format, args...))
c.xcheckf(err, "write command")
fmt.Fprintf(p.xbw, "%s %s\r\n", p.lastTag, fmt.Sprintf(format, args...))
p.xflush()
return
}
func (c *Conn) nextTag() string {
c.tagGen++
return fmt.Sprintf("x%03d", c.tagGen)
func (p *Proto) nextTag() string {
p.tagGen++
p.lastTag = fmt.Sprintf("x%03d", p.tagGen)
return p.lastTag
}
// Response reads from the IMAP server until a tagged response line is found.
// LastTag returns the tag last used for a command. For checking against a command
// completion result.
func (p *Proto) LastTag() string {
return p.lastTag
}
// LastTagSet sets a new last tag, as used for checking against a command completion result.
func (p *Proto) LastTagSet(tag string) {
p.lastTag = tag
}
// ReadResponse reads from the IMAP server until a tagged response line is found.
// The tag must be the same as the tag for the last written command.
// Result holds the status of the command. The caller must check if this the status is OK.
func (c *Conn) Response() (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
//
// If an error is returned, resp can still be non-empty, and a caller may wish to
// process resp.Untagged.
//
// Caller should check resp.Status for the result of the command too.
//
// Common types for the return error:
// - Error, for protocol errors
// - Various I/O errors from the underlying connection, including os.ErrDeadlineExceeded
func (p *Proto) ReadResponse() (resp Response, rerr error) {
defer p.recover(&rerr)
for {
tag := c.xnonspace()
c.xspace()
tag := p.xnonspace()
p.xspace()
if tag == "*" {
untagged = append(untagged, c.xuntagged())
resp.Untagged = append(resp.Untagged, p.xuntagged())
continue
}
if tag != c.LastTag {
c.xerrorf("got tag %q, expected %q", tag, c.LastTag)
if tag != p.lastTag {
p.xerrorf("got tag %q, expected %q", tag, p.lastTag)
}
status := c.xstatus()
c.xspace()
result = c.xresult(status)
c.xcrlf()
status := p.xstatus()
p.xspace()
resp.Result = p.xresult(status)
p.xcrlf()
return
}
}
// ReadUntagged reads a single untagged response line.
// Useful for reading lines from IDLE.
func (c *Conn) ReadUntagged() (untagged Untagged, rerr error) {
defer c.recover(&rerr)
tag := c.xnonspace()
if tag != "*" {
c.xerrorf("got tag %q, expected untagged", tag)
// ParseCode parses a response code. The string must not have enclosing brackets.
//
// Example:
//
// "APPENDUID 123 10"
func ParseCode(s string) (code Code, rerr error) {
p := Proto{br: bufio.NewReader(strings.NewReader(s + "]"))}
defer p.recover(&rerr)
code = p.xrespCode()
p.xtake("]")
buf, err := io.ReadAll(p.br)
p.xcheckf(err, "read")
if len(buf) != 0 {
p.xerrorf("leftover data %q", buf)
}
c.xspace()
ut := c.xuntagged()
return code, nil
}
// ParseResult parses a line, including required crlf, as a command result line.
//
// Example:
//
// "tag1 OK [APPENDUID 123 10] message added\r\n"
func ParseResult(s string) (tag string, result Result, rerr error) {
p := Proto{br: bufio.NewReader(strings.NewReader(s))}
defer p.recover(&rerr)
tag = p.xnonspace()
p.xspace()
status := p.xstatus()
p.xspace()
result = p.xresult(status)
p.xcrlf()
return
}
// ReadUntagged reads a single untagged response line.
func (p *Proto) ReadUntagged() (untagged Untagged, rerr error) {
defer p.recover(&rerr)
return p.readUntagged()
}
// ParseUntagged parses a line, including required crlf, as untagged response.
//
// Example:
//
// "* BYE shutting down connection\r\n"
func ParseUntagged(s string) (untagged Untagged, rerr error) {
p := Proto{br: bufio.NewReader(strings.NewReader(s))}
defer p.recover(&rerr)
untagged, rerr = p.readUntagged()
return
}
func (p *Proto) readUntagged() (untagged Untagged, rerr error) {
defer p.recover(&rerr)
tag := p.xnonspace()
if tag != "*" {
p.xerrorf("got tag %q, expected untagged", tag)
}
p.xspace()
ut := p.xuntagged()
return ut, nil
}
// Readline reads a line, including CRLF.
// Used with IDLE and synchronous literals.
func (c *Conn) Readline() (line string, rerr error) {
defer c.recover(&rerr)
func (p *Proto) Readline() (line string, rerr error) {
defer p.recover(&rerr)
line, err := c.r.ReadString('\n')
c.xcheckf(err, "read line")
line, err := p.br.ReadString('\n')
p.xcheckf(err, "read line")
return line, nil
}
// ReadContinuation reads a line. If it is a continuation, i.e. starts with a +, it
// is returned without leading "+ " and without trailing crlf. Otherwise, a command
// response is returned. A successfully read continuation can return an empty line.
// Callers should check rerr and result.Status being empty to check if a
// continuation was read.
func (c *Conn) ReadContinuation() (line string, untagged []Untagged, result Result, rerr error) {
if !c.peek('+') {
untagged, result, rerr = c.Response()
c.xcheckf(rerr, "reading non-continuation response")
c.xerrorf("response status %q, expected OK", result.Status)
func (c *Conn) readContinuation() (line string, rerr error) {
defer c.recover(&rerr, nil)
line, rerr = c.ReadContinuation()
if rerr != nil {
if resp, ok := rerr.(Response); ok {
c.processUntagged(resp.Untagged)
c.processResult(resp.Result)
}
c.xtake("+ ")
line, err := c.Readline()
c.xcheckf(err, "read line")
}
return
}
// ReadContinuation reads a line. If it is a continuation, i.e. starts with "+", it
// is returned without leading "+ " and without trailing crlf. Otherwise, an error
// is returned, which can be a Response with Untagged that a caller may wish to
// process. A successfully read continuation can return an empty line.
func (p *Proto) ReadContinuation() (line string, rerr error) {
defer p.recover(&rerr)
if !p.peek('+') {
var resp Response
resp, rerr = p.ReadResponse()
if rerr == nil {
rerr = resp
}
return "", rerr
}
p.xtake("+ ")
line, err := p.Readline()
p.xcheckf(err, "read line")
line = strings.TrimSuffix(line, "\r\n")
return
}
// Writelinef writes the formatted format and args as a single line, adding CRLF.
// Used with IDLE and synchronous literals.
func (c *Conn) Writelinef(format string, args ...any) (rerr error) {
defer c.recover(&rerr)
func (p *Proto) Writelinef(format string, args ...any) (rerr error) {
defer p.recover(&rerr)
s := fmt.Sprintf(format, args...)
_, err := fmt.Fprintf(c.conn, "%s\r\n", s)
c.xcheckf(err, "writeline")
fmt.Fprintf(p.xbw, "%s\r\n", s)
p.xflush()
return nil
}
// Write writes directly to the connection. Write errors do take the connections
// panic mode into account, i.e. Write can panic.
func (c *Conn) Write(buf []byte) (n int, rerr error) {
defer c.recover(&rerr)
// WriteSyncLiteral first writes the synchronous literal size, then reads the
// continuation "+" and finally writes the data. If the literal is not accepted, an
// error is returned, which may be a Response.
func (p *Proto) WriteSyncLiteral(s string) (rerr error) {
defer p.recover(&rerr)
n, rerr = c.conn.Write(buf)
c.xcheckf(rerr, "write")
return n, nil
}
fmt.Fprintf(p.xbw, "{%d}\r\n", len(s))
p.xflush()
// WriteSyncLiteral first writes the synchronous literal size, then read the
// continuation "+" and finally writes the data.
func (c *Conn) WriteSyncLiteral(s string) (rerr error) {
defer c.recover(&rerr)
plus, err := p.br.Peek(1)
p.xcheckf(err, "read continuation")
if plus[0] == '+' {
_, err = p.Readline()
p.xcheckf(err, "read continuation line")
_, err := fmt.Fprintf(c.conn, "{%d}\r\n", len(s))
c.xcheckf(err, "write sync literal size")
line, err := c.Readline()
c.xcheckf(err, "read line")
if !strings.HasPrefix(line, "+") {
c.xerrorf("no continuation received for sync literal")
}
_, err = c.conn.Write([]byte(s))
c.xcheckf(err, "write literal data")
defer p.xtracewrite(mlog.LevelTracedata)()
_, err = p.xbw.Write([]byte(s))
p.xcheckf(err, "write literal data")
p.xtracewrite(mlog.LevelTrace)
return nil
}
var resp Response
resp, rerr = p.ReadResponse()
if rerr == nil {
rerr = resp
}
return
}
// Transactf writes format and args as an IMAP command, using Commandf with an
func (c *Conn) processUntagged(l []Untagged) {
for _, ut := range l {
switch e := ut.(type) {
case UntaggedCapability:
c.CapAvailable = []Capability(e)
case UntaggedEnabled:
c.CapEnabled = append(c.CapEnabled, e...)
}
}
}
func (c *Conn) processResult(r Result) {
if r.Code == nil {
return
}
switch e := r.Code.(type) {
case CodeCapability:
c.CapAvailable = []Capability(e)
}
}
// transactf writes format and args as an IMAP command, using Commandf with an
// empty tag. I.e. format must not contain a tag. Transactf then reads a response
// using ReadResponse and checks the result status is OK.
func (c *Conn) Transactf(format string, args ...any) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
func (c *Conn) transactf(format string, args ...any) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
err := c.Commandf("", format, args...)
err := c.WriteCommandf("", format, args...)
if err != nil {
return nil, Result{}, err
}
return c.ResponseOK()
return Response{}, err
}
func (c *Conn) ResponseOK() (untagged []Untagged, result Result, rerr error) {
untagged, result, rerr = c.Response()
if rerr != nil {
return nil, Result{}, rerr
}
if result.Status != OK {
c.xerrorf("response status %q, expected OK", result.Status)
}
return untagged, result, rerr
return c.responseOK()
}
func (c *Conn) xgetUntagged(l []Untagged, dst any) {
if len(l) != 1 {
c.xerrorf("got %d untagged, expected 1: %v", len(l), l)
}
got := l[0]
gotv := reflect.ValueOf(got)
dstv := reflect.ValueOf(dst)
if gotv.Type() != dstv.Type().Elem() {
c.xerrorf("got %v, expected %v", gotv.Type(), dstv.Type().Elem())
}
dstv.Elem().Set(gotv)
}
func (c *Conn) responseOK() (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
// Close closes the connection without writing anything to the server.
// You may want to call Logout. Closing a connection with a mailbox with deleted
// message not yet expunged will not expunge those messages.
func (c *Conn) Close() error {
var err error
if c.conn != nil {
err = c.conn.Close()
c.conn = nil
resp, rerr = c.ReadResponse()
c.processUntagged(resp.Untagged)
c.processResult(resp.Result)
if rerr == nil && resp.Status != OK {
rerr = resp
}
return err
return
}

View File

@ -6,73 +6,121 @@ import (
"encoding/base64"
"fmt"
"hash"
"io"
"strings"
"time"
"github.com/mjl-/flate"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/scram"
)
// Capability requests a list of capabilities from the server. They are returned in
// an UntaggedCapability response. The server also sends capabilities in initial
// server greeting, in the response code.
func (c *Conn) Capability() (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("capability")
// Capability writes the IMAP4 "CAPABILITY" command, requesting a list of
// capabilities from the server. They are returned in an UntaggedCapability
// response. The server also sends capabilities in initial server greeting, in the
// response code.
func (c *Conn) Capability() (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("capability")
}
// Noop does nothing on its own, but a server will return any pending untagged
// responses for new message delivery and changes to mailboxes.
func (c *Conn) Noop() (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("noop")
// Noop writes the IMAP4 "NOOP" command, which does nothing on its own, but a
// server will return any pending untagged responses for new message delivery and
// changes to mailboxes.
func (c *Conn) Noop() (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("noop")
}
// Logout ends the IMAP session by writing a LOGOUT command. Close must still be
// called on this client to close the socket.
func (c *Conn) Logout() (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("logout")
// Logout ends the IMAP4 session by writing an IMAP "LOGOUT" command. [Conn.Close]
// must still be called on this client to close the socket.
func (c *Conn) Logout() (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("logout")
}
// Starttls enables TLS on the connection with the STARTTLS command.
func (c *Conn) Starttls(config *tls.Config) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
untagged, result, rerr = c.Transactf("starttls")
// StartTLS enables TLS on the connection with the IMAP4 "STARTTLS" command.
func (c *Conn) StartTLS(config *tls.Config) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
resp, rerr = c.transactf("starttls")
c.xcheckf(rerr, "starttls command")
conn := tls.Client(c.conn, config)
err := conn.Handshake()
conn := c.xprefixConn()
tlsConn := tls.Client(conn, config)
err := tlsConn.Handshake()
c.xcheckf(err, "tls handshake")
c.conn = conn
c.r = bufio.NewReader(conn)
return untagged, result, nil
}
// Login authenticates with username and password
func (c *Conn) Login(username, password string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("login %s %s", astring(username), astring(password))
}
// Authenticate with plaintext password using AUTHENTICATE PLAIN.
func (c *Conn) AuthenticatePlain(username, password string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
untagged, result, rerr = c.Transactf("authenticate plain %s", base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "\u0000%s\u0000%s", username, password)))
c.conn = tlsConn
return
}
// Authenticate with SCRAM-SHA-256(-PLUS) or SCRAM-SHA-1(-PLUS). With SCRAM, the
// password is not exchanged in plaintext form, but only derived hashes are
// exchanged by both parties as proof of knowledge of password.
// Login authenticates using the IMAP4 "LOGIN" command, sending the plain text
// password to the server.
//
// Authentication is not allowed while the "LOGINDISABLED" capability is announced.
// Call [Conn.StartTLS] first.
//
// See [Conn.AuthenticateSCRAM] for a better authentication mechanism.
func (c *Conn) Login(username, password string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
fmt.Fprintf(c.xbw, "%s login %s ", c.nextTag(), astring(username))
defer c.xtracewrite(mlog.LevelTraceauth)()
fmt.Fprintf(c.xbw, "%s\r\n", astring(password))
c.xtracewrite(mlog.LevelTrace) // Restore.
return c.responseOK()
}
// AuthenticatePlain executes the AUTHENTICATE command with SASL mechanism "PLAIN",
// sending the password in plain text password to the server.
//
// Required capability: "AUTH=PLAIN"
//
// Authentication is not allowed while the "LOGINDISABLED" capability is announced.
// Call [Conn.StartTLS] first.
//
// See [Conn.AuthenticateSCRAM] for a better authentication mechanism.
func (c *Conn) AuthenticatePlain(username, password string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
err := c.WriteCommandf("", "authenticate plain")
c.xcheckf(err, "writing authenticate command")
_, rerr = c.readContinuation()
c.xresponse(rerr, &resp)
defer c.xtracewrite(mlog.LevelTraceauth)()
xw := base64.NewEncoder(base64.StdEncoding, c.xbw)
fmt.Fprintf(xw, "\u0000%s\u0000%s", username, password)
xw.Close()
c.xtracewrite(mlog.LevelTrace) // Restore.
fmt.Fprintf(c.xbw, "\r\n")
c.xflush()
return c.responseOK()
}
// todo: implement cram-md5, write its credentials as traceauth.
// AuthenticateSCRAM executes the IMAP4 "AUTHENTICATE" command with one of the
// following SASL mechanisms: SCRAM-SHA-256(-PLUS) or SCRAM-SHA-1(-PLUS).//
//
// With SCRAM, the password is not sent to the server in plain text, but only
// derived hashes are exchanged by both parties as proof of knowledge of password.
//
// Authentication is not allowed while the "LOGINDISABLED" capability is announced.
// Call [Conn.StartTLS] first.
//
// Required capability: SCRAM-SHA-256-PLUS, SCRAM-SHA-256, SCRAM-SHA-1-PLUS,
// SCRAM-SHA-1.
//
// The PLUS variants bind the authentication exchange to the TLS connection,
// detecting MitM attacks.
func (c *Conn) AuthenticateSCRAM(method string, h func() hash.Hash, username, password string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
func (c *Conn) AuthenticateSCRAM(mechanism string, h func() hash.Hash, username, password string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
var cs *tls.ConnectionState
lmethod := strings.ToLower(method)
if strings.HasSuffix(lmethod, "-plus") {
lmech := strings.ToLower(mechanism)
if strings.HasSuffix(lmech, "-plus") {
tlsConn, ok := c.conn.(*tls.Conn)
if !ok {
c.xerrorf("cannot use scram plus without tls")
@ -83,17 +131,14 @@ func (c *Conn) AuthenticateSCRAM(method string, h func() hash.Hash, username, pa
sc := scram.NewClient(h, username, "", false, cs)
clientFirst, err := sc.ClientFirst()
c.xcheckf(err, "scram clientFirst")
c.LastTag = c.nextTag()
err = c.Writelinef("%s authenticate %s %s", c.LastTag, method, base64.StdEncoding.EncodeToString([]byte(clientFirst)))
// todo: only send clientFirst if server has announced SASL-IR
err = c.Writelinef("%s authenticate %s %s", c.nextTag(), mechanism, base64.StdEncoding.EncodeToString([]byte(clientFirst)))
c.xcheckf(err, "writing command line")
xreadContinuation := func() []byte {
var line string
line, untagged, result, rerr = c.ReadContinuation()
c.xcheckf(err, "read continuation")
if result.Status != "" {
c.xerrorf("unexpected status %q", result.Status)
}
line, rerr = c.readContinuation()
c.xresponse(rerr, &resp)
buf, err := base64.StdEncoding.DecodeString(line)
c.xcheckf(err, "parsing base64 from remote")
return buf
@ -113,83 +158,131 @@ func (c *Conn) AuthenticateSCRAM(method string, h func() hash.Hash, username, pa
err = c.Writelinef("%s", base64.StdEncoding.EncodeToString(nil))
c.xcheckf(err, "scram client end")
return c.ResponseOK()
return c.responseOK()
}
// Enable enables capabilities for use with the connection, verifying the server has indeed enabled them.
func (c *Conn) Enable(capabilities ...string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
// CompressDeflate enables compression with deflate on the connection by executing
// the IMAP4 "COMPRESS=DEFAULT" command.
//
// Required capability: "COMPRESS=DEFLATE".
//
// State: Authenticated or selected.
func (c *Conn) CompressDeflate() (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
untagged, result, rerr = c.Transactf("enable %s", strings.Join(capabilities, " "))
resp, rerr = c.transactf("compress deflate")
c.xcheck(rerr)
var enabled UntaggedEnabled
c.xgetUntagged(untagged, &enabled)
got := map[string]struct{}{}
for _, cap := range enabled {
got[cap] = struct{}{}
}
for _, cap := range capabilities {
if _, ok := got[cap]; !ok {
c.xerrorf("capability %q not enabled by server", cap)
}
}
c.xflateBW = bufio.NewWriter(c)
fw0, err := flate.NewWriter(c.xflateBW, flate.DefaultCompression)
c.xcheckf(err, "deflate") // Cannot happen.
fw := moxio.NewFlateWriter(fw0)
c.compress = true
c.xflateWriter = fw
c.xtw = moxio.NewTraceWriter(mlog.New("imapclient", nil), "CW: ", fw)
c.xbw = bufio.NewWriter(c.xtw)
rc := c.xprefixConn()
fr := flate.NewReaderPartial(rc)
c.tr = moxio.NewTraceReader(mlog.New("imapclient", nil), "CR: ", fr)
c.br = bufio.NewReader(c.tr)
return
}
// Select opens mailbox as active mailbox.
func (c *Conn) Select(mailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("select %s", astring(mailbox))
// Enable enables capabilities for use with the connection by executing the IMAP4 "ENABLE" command.
//
// Required capability: "ENABLE" or "IMAP4rev2"
func (c *Conn) Enable(capabilities ...Capability) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
var caps strings.Builder
for _, c := range capabilities {
caps.WriteString(" " + string(c))
}
return c.transactf("enable%s", caps.String())
}
// Examine opens mailbox as active mailbox read-only.
func (c *Conn) Examine(mailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("examine %s", astring(mailbox))
// Select opens the mailbox with the IMAP4 "SELECT" command.
//
// If a mailbox is selected/active, it is automatically deselected before
// selecting the mailbox, without permanently removing ("expunging") messages
// marked \Deleted.
//
// If the mailbox cannot be opened, the connection is left in Authenticated state,
// not Selected.
func (c *Conn) Select(mailbox string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("select %s", astring(mailbox))
}
// Create makes a new mailbox on the server.
func (c *Conn) Create(mailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("create %s", astring(mailbox))
// Examine opens the mailbox like [Conn.Select], but read-only, with the IMAP4
// "EXAMINE" command.
func (c *Conn) Examine(mailbox string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("examine %s", astring(mailbox))
}
// Delete removes an entire mailbox and its messages.
func (c *Conn) Delete(mailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("delete %s", astring(mailbox))
// Create makes a new mailbox on the server using the IMAP4 "CREATE" command.
//
// SpecialUse can only be used on servers that announced the "CREATE-SPECIAL-USE"
// capability. Specify flags like \Archive, \Drafts, \Junk, \Sent, \Trash, \All.
func (c *Conn) Create(mailbox string, specialUse []string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
var useStr string
if len(specialUse) > 0 {
useStr = fmt.Sprintf(" USE (%s)", strings.Join(specialUse, " "))
}
return c.transactf("create %s%s", astring(mailbox), useStr)
}
// Rename changes the name of a mailbox and all its child mailboxes.
func (c *Conn) Rename(omailbox, nmailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("rename %s %s", astring(omailbox), astring(nmailbox))
// Delete removes an entire mailbox and its messages using the IMAP4 "DELETE"
// command.
func (c *Conn) Delete(mailbox string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("delete %s", astring(mailbox))
}
// Subscribe marks a mailbox as subscribed. The mailbox does not have to exist. It
// is not an error if the mailbox is already subscribed.
func (c *Conn) Subscribe(mailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("subscribe %s", astring(mailbox))
// Rename changes the name of a mailbox and all its child mailboxes
// using the IMAP4 "RENAME" command.
func (c *Conn) Rename(omailbox, nmailbox string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("rename %s %s", astring(omailbox), astring(nmailbox))
}
// Unsubscribe marks a mailbox as unsubscribed.
func (c *Conn) Unsubscribe(mailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("unsubscribe %s", astring(mailbox))
// Subscribe marks a mailbox as subscribed using the IMAP4 "SUBSCRIBE" command.
//
// The mailbox does not have to exist. It is not an error if the mailbox is already
// subscribed.
func (c *Conn) Subscribe(mailbox string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("subscribe %s", astring(mailbox))
}
// List lists mailboxes with the basic LIST syntax.
// Unsubscribe marks a mailbox as unsubscribed using the IMAP4 "UNSUBSCRIBE"
// command.
func (c *Conn) Unsubscribe(mailbox string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("unsubscribe %s", astring(mailbox))
}
// List lists mailboxes using the IMAP4 "LIST" command with the basic LIST syntax.
// Pattern can contain * (match any) or % (match any except hierarchy delimiter).
func (c *Conn) List(pattern string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf(`list "" %s`, astring(pattern))
func (c *Conn) List(pattern string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf(`list "" %s`, astring(pattern))
}
// ListFull lists mailboxes with the extended LIST syntax requesting all supported data.
// ListFull lists mailboxes using the LIST command with the extended LIST
// syntax requesting all supported data.
//
// Required capability: "LIST-EXTENDED". If "IMAP4rev2" is announced, the command
// is also available but only with a single pattern.
//
// Pattern can contain * (match any) or % (match any except hierarchy delimiter).
func (c *Conn) ListFull(subscribedOnly bool, patterns ...string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
func (c *Conn) ListFull(subscribedOnly bool, patterns ...string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
var subscribedStr string
if subscribedOnly {
subscribedStr = "subscribed recursivematch"
@ -197,110 +290,313 @@ func (c *Conn) ListFull(subscribedOnly bool, patterns ...string) (untagged []Unt
for i, s := range patterns {
patterns[i] = astring(s)
}
return c.Transactf(`list (%s) "" (%s) return (subscribed children special-use status (messages uidnext uidvalidity unseen deleted size recent appendlimit))`, subscribedStr, strings.Join(patterns, " "))
return c.transactf(`list (%s) "" (%s) return (subscribed children special-use status (messages uidnext uidvalidity unseen deleted size recent appendlimit))`, subscribedStr, strings.Join(patterns, " "))
}
// Namespace returns the hiearchy separator in an UntaggedNamespace response with personal/shared/other namespaces if present.
func (c *Conn) Namespace() (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("namespace")
// Namespace requests the hiearchy separator using the IMAP4 "NAMESPACE" command.
//
// Required capability: "NAMESPACE" or "IMAP4rev2".
//
// Server will return an UntaggedNamespace response with personal/shared/other
// namespaces if present.
func (c *Conn) Namespace() (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("namespace")
}
// Status requests information about a mailbox, such as number of messages, size, etc.
func (c *Conn) Status(mailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("status %s", astring(mailbox))
// Status requests information about a mailbox using the IMAP4 "STATUS" command. For
// example, number of messages, size, etc. At least one attribute required.
func (c *Conn) Status(mailbox string, attrs ...StatusAttr) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
l := make([]string, len(attrs))
for i, a := range attrs {
l[i] = string(a)
}
return c.transactf("status %s (%s)", astring(mailbox), strings.Join(l, " "))
}
// Append adds message to mailbox with flags and optional receive time.
func (c *Conn) Append(mailbox string, flags []string, received *time.Time, message []byte) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
// Append represents a parameter to the IMAP4 "APPEND" or "REPLACE" commands, for
// adding a message to mailbox, or replacing a message with a new version in a
// mailbox.
type Append struct {
Flags []string // Optional, flags for the new message.
Received *time.Time // Optional, the INTERNALDATE field, typically time at which a message was received.
Size int64
Data io.Reader // Required, must return Size bytes.
}
// Append adds message to mailbox with flags and optional receive time using the
// IMAP4 "APPEND" command.
func (c *Conn) Append(mailbox string, message Append) (resp Response, rerr error) {
return c.MultiAppend(mailbox, message)
}
// MultiAppend atomatically adds multiple messages to the mailbox.
//
// Required capability: "MULTIAPPEND"
func (c *Conn) MultiAppend(mailbox string, message Append, more ...Append) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
fmt.Fprintf(c.xbw, "%s append %s", c.nextTag(), astring(mailbox))
msgs := append([]Append{message}, more...)
for _, m := range msgs {
var date string
if received != nil {
date = ` "` + received.Format("_2-Jan-2006 15:04:05 -0700") + `"`
}
return c.Transactf("append %s (%s)%s {%d+}\r\n%s", astring(mailbox), strings.Join(flags, " "), date, len(message), message)
if m.Received != nil {
date = ` "` + m.Received.Format("_2-Jan-2006 15:04:05 -0700") + `"`
}
// note: No idle command. Idle is better implemented by writing the request and reading and handling the responses as they come in.
// todo: use literal8 if needed, with "UTF8()" if required.
// todo: for larger messages, use a synchronizing literal.
// CloseMailbox closes the currently selected/active mailbox, permanently removing
// any messages marked with \Deleted.
func (c *Conn) CloseMailbox() (untagged []Untagged, result Result, rerr error) {
return c.Transactf("close")
fmt.Fprintf(c.xbw, " (%s)%s {%d+}\r\n", strings.Join(m.Flags, " "), date, m.Size)
defer c.xtracewrite(mlog.LevelTracedata)()
_, err := io.Copy(c.xbw, m.Data)
c.xcheckf(err, "write message data")
c.xtracewrite(mlog.LevelTrace) // Restore
}
// Unselect closes the currently selected/active mailbox, but unlike CloseMailbox
// does not permanently remove any messages marked with \Deleted.
func (c *Conn) Unselect() (untagged []Untagged, result Result, rerr error) {
return c.Transactf("unselect")
fmt.Fprintf(c.xbw, "\r\n")
c.xflush()
return c.responseOK()
}
// Expunge removes messages marked as deleted for the selected mailbox.
func (c *Conn) Expunge() (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("expunge")
// note: No Idle or Notify command. Idle/Notify is better implemented by
// writing the request and reading and handling the responses as they come in.
// CloseMailbox closes the selected/active mailbox using the IMAP4 "CLOSE" command,
// permanently removing ("expunging") any messages marked with \Deleted.
//
// See [Conn.Unselect] for closing a mailbox without permanently removing messages.
func (c *Conn) CloseMailbox() (resp Response, rerr error) {
return c.transactf("close")
}
// UIDExpunge is like expunge, but only removes messages matching uidSet.
func (c *Conn) UIDExpunge(uidSet NumSet) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("uid expunge %s", uidSet.String())
// Unselect closes the selected/active mailbox using the IMAP4 "UNSELECT" command,
// but unlike MailboxClose does not permanently remove ("expunge") any messages
// marked with \Deleted.
//
// Required capability: "UNSELECT" or "IMAP4rev2".
//
// If Unselect is not available, call [Conn.Select] with a non-existent mailbox for
// the same effect: Deselecting a mailbox without permanently removing messages
// marked \Deleted.
func (c *Conn) Unselect() (resp Response, rerr error) {
return c.transactf("unselect")
}
// Expunge removes all messages marked as deleted for the selected mailbox using
// the IMAP4 "EXPUNGE" command. If other sessions marked messages as deleted, even
// if they aren't visible in the session, they are removed as well.
//
// UIDExpunge gives more control over which the messages that are removed.
func (c *Conn) Expunge() (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("expunge")
}
// UIDExpunge is like expunge, but only removes messages matching UID set, using
// the IMAP4 "UID EXPUNGE" command.
//
// Required capability: "UIDPLUS" or "IMAP4rev2".
func (c *Conn) UIDExpunge(uidSet NumSet) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("uid expunge %s", uidSet.String())
}
// Note: No search, fetch command yet due to its large syntax.
// StoreFlagsSet stores a new set of flags for messages from seqset with the STORE command.
// If silent, no untagged responses with the updated flags will be sent by the server.
func (c *Conn) StoreFlagsSet(seqset string, silent bool, flags ...string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
// MSNStoreFlagsSet stores a new set of flags for messages matching message
// sequence numbers (MSNs) from sequence set with the IMAP4 "STORE" command.
//
// If silent, no untagged responses with the updated flags will be sent by the
// server.
//
// Method [Conn.UIDStoreFlagsSet], which operates on a uid set, should be
// preferred.
func (c *Conn) MSNStoreFlagsSet(seqset string, silent bool, flags ...string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
item := "flags"
if silent {
item += ".silent"
}
return c.Transactf("store %s %s (%s)", seqset, item, strings.Join(flags, " "))
return c.transactf("store %s %s (%s)", seqset, item, strings.Join(flags, " "))
}
// StoreFlagsAdd is like StoreFlagsSet, but only adds flags, leaving current flags on the message intact.
func (c *Conn) StoreFlagsAdd(seqset string, silent bool, flags ...string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
// MSNStoreFlagsAdd is like [Conn.MSNStoreFlagsSet], but only adds flags, leaving
// current flags on the message intact.
//
// Method [Conn.UIDStoreFlagsAdd], which operates on a uid set, should be
// preferred.
func (c *Conn) MSNStoreFlagsAdd(seqset string, silent bool, flags ...string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
item := "+flags"
if silent {
item += ".silent"
}
return c.Transactf("store %s %s (%s)", seqset, item, strings.Join(flags, " "))
return c.transactf("store %s %s (%s)", seqset, item, strings.Join(flags, " "))
}
// StoreFlagsClear is like StoreFlagsSet, but only removes flags, leaving other flags on the message intact.
func (c *Conn) StoreFlagsClear(seqset string, silent bool, flags ...string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
// MSNStoreFlagsClear is like [Conn.MSNStoreFlagsSet], but only removes flags,
// leaving other flags on the message intact.
//
// Method [Conn.UIDStoreFlagsClear], which operates on a uid set, should be
// preferred.
func (c *Conn) MSNStoreFlagsClear(seqset string, silent bool, flags ...string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
item := "-flags"
if silent {
item += ".silent"
}
return c.Transactf("store %s %s (%s)", seqset, item, strings.Join(flags, " "))
return c.transactf("store %s %s (%s)", seqset, item, strings.Join(flags, " "))
}
// Copy adds messages from the sequences in seqSet in the currently selected/active mailbox to dstMailbox.
func (c *Conn) Copy(seqSet NumSet, dstMailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("copy %s %s", seqSet.String(), astring(dstMailbox))
// UIDStoreFlagsSet stores a new set of flags for messages matching UIDs from
// uidSet with the IMAP4 "UID STORE" command.
//
// If silent, no untagged responses with the updated flags will be sent by the
// server.
//
// Required capability: "UIDPLUS" or "IMAP4rev2".
func (c *Conn) UIDStoreFlagsSet(uidSet string, silent bool, flags ...string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
item := "flags"
if silent {
item += ".silent"
}
return c.transactf("uid store %s %s (%s)", uidSet, item, strings.Join(flags, " "))
}
// UIDCopy is like copy, but operates on UIDs.
func (c *Conn) UIDCopy(uidSet NumSet, dstMailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("uid copy %s %s", uidSet.String(), astring(dstMailbox))
// UIDStoreFlagsAdd is like UIDStoreFlagsSet, but only adds flags, leaving
// current flags on the message intact.
//
// Required capability: "UIDPLUS" or "IMAP4rev2".
func (c *Conn) UIDStoreFlagsAdd(uidSet string, silent bool, flags ...string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
item := "+flags"
if silent {
item += ".silent"
}
return c.transactf("uid store %s %s (%s)", uidSet, item, strings.Join(flags, " "))
}
// Move moves messages from the sequences in seqSet in the currently selected/active mailbox to dstMailbox.
func (c *Conn) Move(seqSet NumSet, dstMailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("move %s %s", seqSet.String(), astring(dstMailbox))
// UIDStoreFlagsClear is like UIDStoreFlagsSet, but only removes flags, leaving
// other flags on the message intact.
//
// Required capability: "UIDPLUS" or "IMAP4rev2".
func (c *Conn) UIDStoreFlagsClear(uidSet string, silent bool, flags ...string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
item := "-flags"
if silent {
item += ".silent"
}
return c.transactf("uid store %s %s (%s)", uidSet, item, strings.Join(flags, " "))
}
// UIDMove is like move, but operates on UIDs.
func (c *Conn) UIDMove(uidSet NumSet, dstMailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
return c.Transactf("uid move %s %s", uidSet.String(), astring(dstMailbox))
// MSNCopy adds messages from the sequences in the sequence set in the
// selected/active mailbox to destMailbox using the IMAP4 "COPY" command.
//
// Method [Conn.UIDCopy], operating on UIDs instead of sequence numbers, should be
// preferred.
func (c *Conn) MSNCopy(seqSet string, destMailbox string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("copy %s %s", seqSet, astring(destMailbox))
}
// UIDCopy is like copy, but operates on UIDs, using the IMAP4 "UID COPY" command.
//
// Required capability: "UIDPLUS" or "IMAP4rev2".
func (c *Conn) UIDCopy(uidSet string, destMailbox string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("uid copy %s %s", uidSet, astring(destMailbox))
}
// MSNSearch returns messages from the sequence set in the selected/active mailbox
// that match the search critera using the IMAP4 "SEARCH" command.
//
// Method [Conn.UIDSearch], operating on UIDs instead of sequence numbers, should be
// preferred.
func (c *Conn) MSNSearch(seqSet string, criteria string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("seach %s %s", seqSet, criteria)
}
// UIDSearch returns messages from the uid set in the selected/active mailbox that
// match the search critera using the IMAP4 "SEARCH" command.
//
// Criteria is a search program, see RFC 9051 and RFC 3501 for details.
//
// Required capability: "UIDPLUS" or "IMAP4rev2".
func (c *Conn) UIDSearch(seqSet string, criteria string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("seach %s %s", seqSet, criteria)
}
// MSNMove moves messages from the sequence set in the selected/active mailbox to
// destMailbox using the IMAP4 "MOVE" command.
//
// Required capability: "MOVE" or "IMAP4rev2".
//
// Method [Conn.UIDMove], operating on UIDs instead of sequence numbers, should be
// preferred.
func (c *Conn) MSNMove(seqSet string, destMailbox string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("move %s %s", seqSet, astring(destMailbox))
}
// UIDMove is like move, but operates on UIDs, using the IMAP4 "UID MOVE" command.
//
// Required capability: "MOVE" or "IMAP4rev2".
func (c *Conn) UIDMove(uidSet string, destMailbox string) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
return c.transactf("uid move %s %s", uidSet, astring(destMailbox))
}
// MSNReplace is like the preferred [Conn.UIDReplace], but operates on a message
// sequence number (MSN) instead of a UID.
//
// Required capability: "REPLACE".
//
// Method [Conn.UIDReplace], operating on UIDs instead of sequence numbers, should be
// preferred.
func (c *Conn) MSNReplace(msgseq string, mailbox string, msg Append) (resp Response, rerr error) {
// todo: parse msgseq, must be nznumber, with a known msgseq. or "*" with at least one message.
return c.replace("replace", msgseq, mailbox, msg)
}
// UIDReplace uses the IMAP4 "UID REPLACE" command to replace a message from the
// selected/active mailbox with a new/different version of the message in the named
// mailbox, which may be the same or different than the selected mailbox.
//
// The replaced message is indicated by uid.
//
// Required capability: "REPLACE".
func (c *Conn) UIDReplace(uid string, mailbox string, msg Append) (resp Response, rerr error) {
// todo: parse uid, must be nznumber, with a known uid. or "*" with at least one message.
return c.replace("uid replace", uid, mailbox, msg)
}
func (c *Conn) replace(cmd string, num string, mailbox string, msg Append) (resp Response, rerr error) {
defer c.recover(&rerr, &resp)
// todo: use synchronizing literal for larger messages.
var date string
if msg.Received != nil {
date = ` "` + msg.Received.Format("_2-Jan-2006 15:04:05 -0700") + `"`
}
// todo: only use literal8 if needed, possibly with "UTF8()"
// todo: encode mailbox
err := c.WriteCommandf("", "%s %s %s (%s)%s ~{%d+}", cmd, num, astring(mailbox), strings.Join(msg.Flags, " "), date, msg.Size)
c.xcheckf(err, "writing replace command")
defer c.xtracewrite(mlog.LevelTracedata)()
_, err = io.Copy(c.xbw, msg.Data)
c.xcheckf(err, "write message data")
c.xtracewrite(mlog.LevelTrace)
fmt.Fprintf(c.xbw, "\r\n")
c.xflush()
return c.responseOK()
}

38
imapclient/fuzz_test.go Normal file
View File

@ -0,0 +1,38 @@
package imapclient
import (
"os"
"strings"
"testing"
)
func FuzzParser(f *testing.F) {
/*
Gathering all untagged responses and command completion results from the RFCs:
cd ../rfc
(
grep ' S: \* [A-Z]' * | sed 's/^.*S: //g'
grep -E ' S: [^ *]+ (OK|NO|BAD) ' * | sed 's/^.*S: //g'
) | grep -v '\.\.\/' | sort | uniq >../testdata/imapclient/fuzzseed.txt
*/
buf, err := os.ReadFile("../testdata/imapclient/fuzzseed.txt")
if err != nil {
f.Fatalf("reading seed: %v", err)
}
for _, s := range strings.Split(string(buf), "\n") {
f.Add(s + "\r\n")
}
f.Add("1:3")
f.Add("3:1")
f.Add("3,1")
f.Add("*")
f.Fuzz(func(t *testing.T, data string) {
ParseUntagged(data)
ParseCode(data)
ParseResult(data)
ParseNumSet(data)
ParseUIDRange(data)
})
}

File diff suppressed because it is too large Load Diff

42
imapclient/parse_test.go Normal file
View File

@ -0,0 +1,42 @@
package imapclient
import (
"fmt"
"reflect"
"testing"
)
func tcheckf(t *testing.T, err error, format string, args ...any) {
if err != nil {
t.Fatalf("%s: %s", fmt.Sprintf(format, args...), err)
}
}
func tcompare(t *testing.T, a, b any) {
if !reflect.DeepEqual(a, b) {
t.Fatalf("got:\n%#v\nexpected:\n%#v", a, b)
}
}
func uint32ptr(v uint32) *uint32 { return &v }
func TestParse(t *testing.T) {
code, err := ParseCode("COPYUID 1 1:3 2:4")
tcheckf(t, err, "parsing code")
tcompare(t, code,
CodeCopyUID{
DestUIDValidity: 1,
From: []NumRange{{First: 1, Last: uint32ptr(3)}},
To: []NumRange{{First: 2, Last: uint32ptr(4)}},
},
)
ut, err := ParseUntagged("* BYE done\r\n")
tcheckf(t, err, "parsing untagged")
tcompare(t, ut, UntaggedBye{Text: "done"})
tag, result, err := ParseResult("tag1 OK [ALERT] Hello\r\n")
tcheckf(t, err, "parsing result")
tcompare(t, tag, "tag1")
tcompare(t, result, Result{Status: OK, Code: CodeWord("ALERT"), Text: "Hello"})
}

41
imapclient/prefixconn.go Normal file
View File

@ -0,0 +1,41 @@
package imapclient
import (
"io"
"net"
)
// prefixConn is a net.Conn with a buffer from which the first reads are satisfied.
// used for STARTTLS where already did a buffered read of initial TLS data.
type prefixConn struct {
prefix []byte
net.Conn
}
func (c *prefixConn) Read(buf []byte) (int, error) {
if len(c.prefix) > 0 {
n := min(len(buf), len(c.prefix))
copy(buf[:n], c.prefix[:n])
c.prefix = c.prefix[n:]
if len(c.prefix) == 0 {
c.prefix = nil
}
return n, nil
}
return c.Conn.Read(buf)
}
// xprefixConn checks if there are any buffered unconsumed reads. If not, it
// returns c.conn. Otherwise, it returns a *prefixConn from which the buffered data
// can be read followed by data from c.conn.
func (c *Conn) xprefixConn() net.Conn {
n := c.br.Buffered()
if n == 0 {
return c.conn
}
buf := make([]byte, n)
_, err := io.ReadFull(c.br, buf)
c.xcheckf(err, "get buffered data")
return &prefixConn{buf, c.conn}
}

View File

@ -2,35 +2,57 @@ package imapclient
import (
"bufio"
"errors"
"fmt"
"strings"
"time"
)
// Capability is a known string for with the ENABLED and CAPABILITY command.
// Capability is a known string for with the ENABLED command and response and
// CAPABILITY responses. Servers could send unknown values. Always in upper case.
type Capability string
const (
CapIMAP4rev1 Capability = "IMAP4rev1"
CapIMAP4rev2 Capability = "IMAP4rev2"
CapLoginDisabled Capability = "LOGINDISABLED"
CapStarttls Capability = "STARTTLS"
CapAuthPlain Capability = "AUTH=PLAIN"
CapLiteralPlus Capability = "LITERAL+"
CapLiteralMinus Capability = "LITERAL-"
CapIdle Capability = "IDLE"
CapNamespace Capability = "NAMESPACE"
CapBinary Capability = "BINARY"
CapUnselect Capability = "UNSELECT"
CapUidplus Capability = "UIDPLUS"
CapEsearch Capability = "ESEARCH"
CapEnable Capability = "ENABLE"
CapSave Capability = "SAVE"
CapListExtended Capability = "LIST-EXTENDED"
CapSpecialUse Capability = "SPECIAL-USE"
CapMove Capability = "MOVE"
CapIMAP4rev1 Capability = "IMAP4REV1" // ../rfc/3501:1310
CapIMAP4rev2 Capability = "IMAP4REV2" // ../rfc/9051:1219
CapLoginDisabled Capability = "LOGINDISABLED" // ../rfc/3501:3792 ../rfc/9051:5436
CapStartTLS Capability = "STARTTLS" // ../rfc/3501:1327 ../rfc/9051:1238
CapAuthPlain Capability = "AUTH=PLAIN" // ../rfc/3501:1327 ../rfc/9051:1238
CapAuthExternal Capability = "AUTH=EXTERNAL" // ../rfc/4422:1575
CapAuthSCRAMSHA256Plus Capability = "AUTH=SCRAM-SHA-256-PLUS" // ../rfc/7677:80
CapAuthSCRAMSHA256 Capability = "AUTH=SCRAM-SHA-256"
CapAuthSCRAMSHA1Plus Capability = "AUTH=SCRAM-SHA-1-PLUS" // ../rfc/5802:465
CapAuthSCRAMSHA1 Capability = "AUTH=SCRAM-SHA-1"
CapAuthCRAMMD5 Capability = "AUTH=CRAM-MD5" // ../rfc/2195:80
CapLiteralPlus Capability = "LITERAL+" // ../rfc/2088:45
CapLiteralMinus Capability = "LITERAL-" // ../rfc/7888:26 ../rfc/9051:847 Default since IMAP4rev2
CapIdle Capability = "IDLE" // ../rfc/2177:69 ../rfc/9051:3542 Default since IMAP4rev2
CapNamespace Capability = "NAMESPACE" // ../rfc/2342:130 ../rfc/9051:135 Default since IMAP4rev2
CapBinary Capability = "BINARY" // ../rfc/3516:100
CapUnselect Capability = "UNSELECT" // ../rfc/3691:78 ../rfc/9051:3667 Default since IMAP4rev2
CapUidplus Capability = "UIDPLUS" // ../rfc/4315:36 ../rfc/9051:8015 Default since IMAP4rev2
CapEsearch Capability = "ESEARCH" // ../rfc/4731:69 ../rfc/9051:8016 Default since IMAP4rev2
CapEnable Capability = "ENABLE" // ../rfc/5161:52 ../rfc/9051:8016 Default since IMAP4rev2
CapListExtended Capability = "LIST-EXTENDED" // ../rfc/5258:150 ../rfc/9051:7987 Syntax except multiple mailboxes default since IMAP4rev2
CapSpecialUse Capability = "SPECIAL-USE" // ../rfc/6154:156 ../rfc/9051:8021 Special-use attributes in LIST responses by default since IMAP4rev2
CapMove Capability = "MOVE" // ../rfc/6851:87 ../rfc/9051:8018 Default since IMAP4rev2
CapUTF8Only Capability = "UTF8=ONLY"
CapUTF8Accept Capability = "UTF8=ACCEPT"
CapCondstore Capability = "CONDSTORE" // ../rfc/7162:411
CapQresync Capability = "QRESYNC" // ../rfc/7162:1376
CapID Capability = "ID" // ../rfc/2971:80
CapMetadata Capability = "METADATA" // ../rfc/5464:124
CapMetadataServer Capability = "METADATA-SERVER" // ../rfc/5464:124
CapSaveDate Capability = "SAVEDATE" // ../rfc/8514
CapCreateSpecialUse Capability = "CREATE-SPECIAL-USE" // ../rfc/6154:296
CapCompressDeflate Capability = "COMPRESS=DEFLATE" // ../rfc/4978:65
CapListMetadata Capability = "LIST-METADATA" // ../rfc/9590:73
CapMultiAppend Capability = "MULTIAPPEND" // ../rfc/3502:33
CapReplace Capability = "REPLACE" // ../rfc/8508:155
CapPreview Capability = "PREVIEW" // ../rfc/8970:114
CapMultiSearch Capability = "MULTISEARCH" // ../rfc/7377:187
CapNotify Capability = "NOTIFY" // ../rfc/5465:195
CapUIDOnly Capability = "UIDONLY" // ../rfc/9586:129
)
// Status is the tagged final result of a command.
@ -42,73 +64,144 @@ const (
OK Status = "OK" // Command succeeded.
)
// Response is a response to an IMAP command including any preceding untagged
// responses. Response implements the error interface through result.
//
// See [UntaggedResponseGet] and [UntaggedResponseList] to retrieve specific types
// of untagged responses.
type Response struct {
Untagged []Untagged
Result
}
var (
ErrMissing = errors.New("no response of type") // Returned by UntaggedResponseGet.
ErrMultiple = errors.New("multiple responses of type") // Idem.
)
// UntaggedResponseGet returns the single untagged response of type T. Only
// [ErrMissing] or [ErrMultiple] can be returned as error.
func UntaggedResponseGet[T Untagged](resp Response) (T, error) {
var t T
var have bool
for _, e := range resp.Untagged {
if tt, ok := e.(T); ok {
if have {
return t, ErrMultiple
}
t = tt
}
}
if !have {
return t, ErrMissing
}
return t, nil
}
// UntaggedResponseList returns all untagged responses of type T.
func UntaggedResponseList[T Untagged](resp Response) []T {
var l []T
for _, e := range resp.Untagged {
if tt, ok := e.(T); ok {
l = append(l, tt)
}
}
return l
}
// Result is the final response for a command, indicating success or failure.
type Result struct {
Status Status
RespText
Code Code // Set if response code is present.
Text string // Any remaining text.
}
// CodeArg represents a response code with arguments, i.e. the data between [] in the response line.
type CodeArg interface {
func (r Result) Error() string {
s := fmt.Sprintf("IMAP result %s", r.Status)
if r.Code != nil {
s += "[" + r.Code.CodeString() + "]"
}
if r.Text != "" {
s += " " + r.Text
}
return s
}
// Code represents a response code with optional arguments, i.e. the data between [] in the response line.
type Code interface {
CodeString() string
}
// CodeOther is a valid but unrecognized response code.
type CodeOther struct {
Code string
// CodeWord is a response code without parameters, always in upper case.
type CodeWord string
func (c CodeWord) CodeString() string {
return string(c)
}
// CodeOther is an unrecognized response code with parameters.
type CodeParams struct {
Code string // Always in upper case.
Args []string
}
func (c CodeOther) CodeString() string {
func (c CodeParams) CodeString() string {
return c.Code + " " + strings.Join(c.Args, " ")
}
// CodeWords is a code with space-separated string parameters. E.g. CAPABILITY.
type CodeWords struct {
Code string
Args []string
// CodeCapability is a CAPABILITY response code with the capabilities supported by the server.
type CodeCapability []Capability
func (c CodeCapability) CodeString() string {
var s string
for _, c := range c {
s += " " + string(c)
}
return "CAPABILITY" + s
}
func (c CodeWords) CodeString() string {
s := c.Code
for _, w := range c.Args {
s += " " + w
}
type CodeBadCharset []string
func (c CodeBadCharset) CodeString() string {
s := "BADCHARSET"
if len(c) == 0 {
return s
}
// CodeList is a code with a list with space-separated strings as parameters. E.g. BADCHARSET, PERMANENTFLAGS.
type CodeList struct {
Code string
Args []string // If nil, no list was present. List can also be empty.
return s + " (" + strings.Join([]string(c), " ") + ")"
}
func (c CodeList) CodeString() string {
s := c.Code
if c.Args == nil {
return s
}
return s + "(" + strings.Join(c.Args, " ") + ")"
type CodePermanentFlags []string
func (c CodePermanentFlags) CodeString() string {
return "PERMANENTFLAGS (" + strings.Join([]string(c), " ") + ")"
}
// CodeUint is a code with a uint32 parameter, e.g. UIDNEXT and UIDVALIDITY.
type CodeUint struct {
Code string
Num uint32
type CodeUIDNext uint32
func (c CodeUIDNext) CodeString() string {
return fmt.Sprintf("UIDNEXT %d", c)
}
func (c CodeUint) CodeString() string {
return fmt.Sprintf("%s %d", c.Code, c.Num)
type CodeUIDValidity uint32
func (c CodeUIDValidity) CodeString() string {
return fmt.Sprintf("UIDVALIDITY %d", c)
}
type CodeUnseen uint32
func (c CodeUnseen) CodeString() string {
return fmt.Sprintf("UNSEEN %d", c)
}
// "APPENDUID" response code.
type CodeAppendUID struct {
UIDValidity uint32
UID uint32
UIDs NumRange
}
func (c CodeAppendUID) CodeString() string {
return fmt.Sprintf("APPENDUID %d %d", c.UIDValidity, c.UID)
return fmt.Sprintf("APPENDUID %d %s", c.UIDValidity, c.UIDs.String())
}
// "COPYUID" response code.
@ -149,11 +242,66 @@ func (c CodeHighestModSeq) CodeString() string {
return fmt.Sprintf("HIGHESTMODSEQ %d", c)
}
// RespText represents a response line minus the leading tag.
type RespText struct {
Code string // The first word between [] after the status.
CodeArg CodeArg // Set if code has a parameter.
More string // Any remaining text.
// "INPROGRESS" response code.
type CodeInProgress struct {
Tag string // Nil is empty string.
Current *uint32
Goal *uint32
}
func (c CodeInProgress) CodeString() string {
// ABNF allows inprogress-tag/state with all nil values. Doesn't seem useful enough
// to keep track of.
if c.Tag == "" && c.Current == nil && c.Goal == nil {
return "INPROGRESS"
}
// todo: quote tag properly
current := "nil"
goal := "nil"
if c.Current != nil {
current = fmt.Sprintf("%d", *c.Current)
}
if c.Goal != nil {
goal = fmt.Sprintf("%d", *c.Goal)
}
return fmt.Sprintf("INPROGRESS (%q %s %s)", c.Tag, current, goal)
}
// "BADEVENT" response code, with the events that are supported, for the NOTIFY
// extension.
type CodeBadEvent []string
func (c CodeBadEvent) CodeString() string {
return fmt.Sprintf("BADEVENT (%s)", strings.Join([]string(c), " "))
}
// "METADATA LONGENTRIES number" response for GETMETADATA command.
type CodeMetadataLongEntries uint32
func (c CodeMetadataLongEntries) CodeString() string {
return fmt.Sprintf("METADATA LONGENTRIES %d", c)
}
// "METADATA (MAXSIZE number)" response for SETMETADATA command.
type CodeMetadataMaxSize uint32
func (c CodeMetadataMaxSize) CodeString() string {
return fmt.Sprintf("METADATA (MAXSIZE %d)", c)
}
// "METADATA (TOOMANY)" response for SETMETADATA command.
type CodeMetadataTooMany struct{}
func (c CodeMetadataTooMany) CodeString() string {
return "METADATA (TOOMANY)"
}
// "METADATA (NOPRIVATE)" response for SETMETADATA command.
type CodeMetadataNoPrivate struct{}
func (c CodeMetadataNoPrivate) CodeString() string {
return "METADATA (NOPRIVATE)"
}
// atom or string.
@ -194,17 +342,30 @@ func syncliteral(s string) string {
// todo: make an interface that the untagged responses implement?
type Untagged any
type UntaggedBye RespText
type UntaggedPreauth RespText
type UntaggedBye struct {
Code Code // Set if response code is present.
Text string // Any remaining text.
}
type UntaggedPreauth struct {
Code Code // Set if response code is present.
Text string // Any remaining text.
}
type UntaggedExpunge uint32
type UntaggedExists uint32
type UntaggedRecent uint32
type UntaggedCapability []string
type UntaggedEnabled []string
// UntaggedCapability lists all capabilities the server implements.
type UntaggedCapability []Capability
// UntaggedEnabled indicates the capabilities that were enabled on the connection
// by the server, typically in response to an ENABLE command.
type UntaggedEnabled []Capability
type UntaggedResult Result
type UntaggedFlags []string
type UntaggedList struct {
// ../rfc/9051:6690
Flags []string
Separator byte // 0 for NIL
Mailbox string
@ -215,22 +376,76 @@ type UntaggedFetch struct {
Seq uint32
Attrs []FetchAttr
}
// UntaggedUIDFetch is like UntaggedFetch, but with UIDs instead of message
// sequence numbers, and returned instead of regular fetch responses when UIDONLY
// is enabled.
type UntaggedUIDFetch struct {
UID uint32
Attrs []FetchAttr
}
type UntaggedSearch []uint32
// ../rfc/7162:1101
type UntaggedSearchModSeq struct {
// ../rfc/7162:1101
Nums []uint32
ModSeq int64
}
type UntaggedStatus struct {
Mailbox string
Attrs map[string]int64 // Upper case status attributes. ../rfc/9051:7059
Attrs map[StatusAttr]int64 // Upper case status attributes.
}
// Unsolicited response, indicating an annotation has changed.
type UntaggedMetadataKeys struct {
// ../rfc/5464:716
Mailbox string // Empty means not specific to mailbox.
// Keys that have changed. To get values (or determine absence), the server must be
// queried.
Keys []string
}
// Annotation is a metadata server of mailbox annotation.
type Annotation struct {
Key string
// Nil is represented by IsString false and a nil Value.
IsString bool
Value []byte
}
type UntaggedMetadataAnnotations struct {
// ../rfc/5464:683
Mailbox string // Empty means not specific to mailbox.
Annotations []Annotation
}
type StatusAttr string
// ../rfc/9051:7059 ../9208:712
const (
StatusMessages StatusAttr = "MESSAGES"
StatusUIDNext StatusAttr = "UIDNEXT"
StatusUIDValidity StatusAttr = "UIDVALIDITY"
StatusUnseen StatusAttr = "UNSEEN"
StatusDeleted StatusAttr = "DELETED"
StatusSize StatusAttr = "SIZE"
StatusRecent StatusAttr = "RECENT"
StatusAppendLimit StatusAttr = "APPENDLIMIT"
StatusHighestModSeq StatusAttr = "HIGHESTMODSEQ"
StatusDeletedStorage StatusAttr = "DELETED-STORAGE"
)
type UntaggedNamespace struct {
Personal, Other, Shared []NamespaceDescr
}
type UntaggedLsub struct {
// ../rfc/3501:4833
Flags []string
Separator byte
Mailbox string
@ -238,8 +453,10 @@ type UntaggedLsub struct {
// Fields are optional and zero if absent.
type UntaggedEsearch struct {
// ../rfc/9051:6546
Correlator string
Tag string // ../rfc/9051:6546
Mailbox string // For MULTISEARCH. ../rfc/7377:437
UIDValidity uint32 // For MULTISEARCH, ../rfc/7377:438
UID bool
Min uint32
Max uint32
@ -255,6 +472,37 @@ type UntaggedVanished struct {
UIDs NumSet
}
// UntaggedQuotaroot lists the roots for which quota can be present.
type UntaggedQuotaroot []string
// UntaggedQuota holds the quota for a quota root.
type UntaggedQuota struct {
Root string
// Always has at least one. Any QUOTA=RES-* capability not mentioned has no limit
// or this quota root.
Resources []QuotaResource
}
// Resource types ../rfc/9208:533
// QuotaResourceName is the name of a resource type. More can be defined in the
// future and encountered in the wild. Always in upper case.
type QuotaResourceName string
const (
QuotaResourceStorage = "STORAGE"
QuotaResourceMesssage = "MESSAGE"
QuotaResourceMailbox = "MAILBOX"
QuotaResourceAnnotationStorage = "ANNOTATION-STORAGE"
)
type QuotaResource struct {
Name QuotaResourceName
Usage int64 // Currently in use. Count or disk size in 1024 byte blocks.
Limit int64 // Maximum allowed usage.
}
// ../rfc/2971:184
type UntaggedID map[string]string
@ -267,6 +515,7 @@ type EsearchDataExt struct {
type NamespaceDescr struct {
// ../rfc/9051:6769
Prefix string
Separator byte // If 0 then separator was absent.
Exts []NamespaceExtension
@ -274,13 +523,14 @@ type NamespaceDescr struct {
type NamespaceExtension struct {
// ../rfc/9051:6773
Key string
Values []string
}
// FetchAttr represents a FETCH response attribute.
type FetchAttr interface {
Attr() string // Name of attribute.
Attr() string // Name of attribute in upper case, e.g. "UID".
}
type NumSet struct {
@ -307,12 +557,19 @@ func (ns NumSet) String() string {
}
func ParseNumSet(s string) (ns NumSet, rerr error) {
c := Conn{r: bufio.NewReader(strings.NewReader(s))}
c := Proto{br: bufio.NewReader(strings.NewReader(s))}
defer c.recover(&rerr)
ns = c.xsequenceSet()
return
}
func ParseUIDRange(s string) (nr NumRange, rerr error) {
c := Proto{br: bufio.NewReader(strings.NewReader(s))}
defer c.recover(&rerr)
nr = c.xuidrange()
return
}
// NumRange is a single number or range.
type NumRange struct {
First uint32 // 0 for "*".
@ -346,6 +603,7 @@ type TaggedExtComp struct {
type TaggedExtVal struct {
// ../rfc/9051:7111
Number *int64
SeqSet *NumSet
Comp *TaggedExtComp // If SimpleNumber and SimpleSeqSet is nil, this is a Comp. But Comp is optional and can also be nil. Not great.
@ -353,6 +611,7 @@ type TaggedExtVal struct {
type MboxListExtendedItem struct {
// ../rfc/9051:6699
Tag string
Val TaggedExtVal
}
@ -381,9 +640,21 @@ type Address struct {
}
// "INTERNALDATE" fetch response.
type FetchInternalDate string // todo: parsed time
type FetchInternalDate struct {
Date time.Time
}
func (f FetchInternalDate) Attr() string { return "INTERNALDATE" }
// "SAVEDATE" fetch response.
type FetchSaveDate struct {
// ../rfc/8514:265
SaveDate *time.Time // nil means absent for message.
}
func (f FetchSaveDate) Attr() string { return "SAVEDATE" }
// "RFC822.SIZE" fetch response.
type FetchRFC822Size int64
@ -407,6 +678,7 @@ func (f FetchRFC822Text) Attr() string { return "RFC822.TEXT" }
// "BODYSTRUCTURE" fetch response.
type FetchBodystructure struct {
// ../rfc/9051:6355
RespAttr string
Body any // BodyType*
}
@ -416,6 +688,7 @@ func (f FetchBodystructure) Attr() string { return f.RespAttr }
// "BODY" fetch response.
type FetchBody struct {
// ../rfc/9051:6756 ../rfc/9051:6985
RespAttr string
Section string // todo: parse more ../rfc/9051:6985
Offset int32
@ -431,36 +704,96 @@ type BodyFields struct {
Octets int32
}
// BodyTypeMpart represents the body structure a multipart message, with subparts and the multipart media subtype. Used in a FETCH response.
// BodyTypeMpart represents the body structure a multipart message, with
// subparts and the multipart media subtype. Used in a FETCH response.
type BodyTypeMpart struct {
// ../rfc/9051:6411
Bodies []any // BodyTypeBasic, BodyTypeMsg, BodyTypeText
MediaSubtype string
Ext *BodyExtensionMpart
}
// BodyTypeBasic represents basic information about a part, used in a FETCH response.
// BodyTypeBasic represents basic information about a part, used in a FETCH
// response.
type BodyTypeBasic struct {
// ../rfc/9051:6407
MediaType, MediaSubtype string
BodyFields BodyFields
Ext *BodyExtension1Part
}
// BodyTypeMsg represents an email message as a body structure, used in a FETCH response.
// BodyTypeMsg represents an email message as a body structure, used in a FETCH
// response.
type BodyTypeMsg struct {
// ../rfc/9051:6415
MediaType, MediaSubtype string
BodyFields BodyFields
Envelope Envelope
Bodystructure any // One of the BodyType*
Lines int64
Ext *BodyExtension1Part
}
// BodyTypeText represents a text part as a body structure, used in a FETCH response.
// BodyTypeText represents a text part as a body structure, used in a FETCH
// response.
type BodyTypeText struct {
// ../rfc/9051:6418
MediaType, MediaSubtype string
BodyFields BodyFields
Lines int64
Ext *BodyExtension1Part
}
// BodyExtension1Part has the extensible form fields of a BODYSTRUCTURE for
// multiparts.
//
// Fields in this struct are optional in IMAP4, and can be NIL or contain a value.
// The first field is always present, otherwise the "parent" struct would have a
// nil *BodyExtensionMpart. The second and later fields are nil when absent. For
// non-reference types (e.g. strings), an IMAP4 NIL is represented as a pointer to
// (*T)(nil). For reference types (e.g. slices), an IMAP4 NIL is represented by a
// pointer to nil.
type BodyExtensionMpart struct {
// ../rfc/9051:5986 ../rfc/3501:4161 ../rfc/9051:6371 ../rfc/3501:4599
Params [][2]string
Disposition **string
DispositionParams *[][2]string
Language *[]string
Location **string
More []BodyExtension // Nil if absent.
}
// BodyExtension1Part has the extensible form fields of a BODYSTRUCTURE for
// non-multiparts.
//
// Fields in this struct are optional in IMAP4, and can be NIL or contain a value.
// The first field is always present, otherwise the "parent" struct would have a
// nil *BodyExtensionMpart. The second and later fields are nil when absent. For
// non-reference types (e.g. strings), an IMAP4 NIL is represented as a pointer to
// (*T)(nil). For reference types (e.g. slices), an IMAP4 NIL is represented by a
// pointer to nil.
type BodyExtension1Part struct {
// ../rfc/9051:6023 ../rfc/3501:4191 ../rfc/9051:6366 ../rfc/3501:4584
MD5 *string
Disposition **string
DispositionParams *[][2]string
Language *[]string
Location **string
More []BodyExtension // Nil means absent.
}
// BodyExtension has the additional extension fields for future expansion of
// extensions.
type BodyExtension struct {
String *string
Number *int64
More []BodyExtension
}
// "BINARY" fetch response.
@ -490,3 +823,12 @@ func (f FetchUID) Attr() string { return "UID" }
type FetchModSeq int64
func (f FetchModSeq) Attr() string { return "MODSEQ" }
// "PREVIEW" fetch response.
type FetchPreview struct {
Preview *string
}
// ../rfc/8970:146
func (f FetchPreview) Attr() string { return "PREVIEW" }

View File

@ -7,22 +7,30 @@ import (
)
func TestAppend(t *testing.T) {
testAppend(t, false)
}
func TestAppendUIDOnly(t *testing.T) {
testAppend(t, true)
}
func testAppend(t *testing.T, uidonly bool) {
defer mockUIDValidity()()
tc := start(t) // note: with switchboard because this connection stays alive unlike tc2.
tc := start(t, uidonly) // note: with switchboard because this connection stays alive unlike tc2.
defer tc.close()
tc2 := startNoSwitchboard(t) // note: without switchboard because this connection will break during tests.
defer tc2.close()
tc2 := startNoSwitchboard(t, uidonly) // note: without switchboard because this connection will break during tests.
defer tc2.closeNoWait()
tc3 := startNoSwitchboard(t)
defer tc3.close()
tc3 := startNoSwitchboard(t, uidonly)
defer tc3.closeNoWait()
tc2.client.Login("mjl@mox.example", "testtest")
tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox")
tc.client.Login("mjl@mox.example", "testtest")
tc.login("mjl@mox.example", password0)
tc.client.Select("inbox")
tc3.client.Login("mjl@mox.example", "testtest")
tc3.login("mjl@mox.example", password0)
tc2.transactf("bad", "append") // Missing params.
tc2.transactf("bad", `append inbox`) // Missing message.
@ -30,43 +38,44 @@ func TestAppend(t *testing.T) {
// Syntax error for line ending in literal causes connection abort.
tc2.transactf("bad", "append inbox (\\Badflag) {1+}\r\nx") // Unknown flag.
tc2 = startNoSwitchboard(t)
defer tc2.close()
tc2.client.Login("mjl@mox.example", "testtest")
tc2 = startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait()
tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox")
tc2.transactf("bad", "append inbox () \"bad time\" {1+}\r\nx") // Bad time.
tc2 = startNoSwitchboard(t)
defer tc2.close()
tc2.client.Login("mjl@mox.example", "testtest")
tc2 = startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait()
tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox")
tc2.transactf("no", "append nobox (\\Seen) \" 1-Jan-2022 10:10:00 +0100\" {1}")
tc2.xcode("TRYCREATE")
tc2.xcodeWord("TRYCREATE")
tc2.transactf("no", "append expungebox (\\Seen) {1}")
tc2.xcodeWord("TRYCREATE")
tc2.transactf("ok", "append inbox (\\Seen Label1 $label2) \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
tc2.xuntagged(imapclient.UntaggedExists(1))
tc2.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 1, UID: 1})
tc2.xcode(imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("1")})
tc.transactf("ok", "noop")
uid1 := imapclient.FetchUID(1)
flags := imapclient.FetchFlags{`\Seen`, "$label2", "label1"}
tc.xuntagged(imapclient.UntaggedExists(1), imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flags}})
tc.xuntagged(imapclient.UntaggedExists(1), tc.untaggedFetch(1, 1, flags))
tc3.transactf("ok", "noop")
tc3.xuntagged() // Inbox is not selected, nothing to report.
tc2.transactf("ok", "append inbox (\\Seen) \" 1-Jan-2022 10:10:00 +0100\" UTF8 ({47+}\r\ncontent-type: just completely invalid;;\r\n\r\ntest)")
tc2.transactf("ok", "append inbox (\\Seen) \" 1-Jan-2022 10:10:00 +0100\" UTF8 (~{47+}\r\ncontent-type: just completely invalid;;\r\n\r\ntest)")
tc2.xuntagged(imapclient.UntaggedExists(2))
tc2.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 1, UID: 2})
tc2.xcode(imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("2")})
tc2.transactf("ok", "append inbox (\\Seen) \" 1-Jan-2022 10:10:00 +0100\" UTF8 ({31+}\r\ncontent-type: text/plain;\n\ntest)")
tc2.transactf("ok", "append inbox (\\Seen) \" 1-Jan-2022 10:10:00 +0100\" UTF8 (~{31+}\r\ncontent-type: text/plain;\n\ntest)")
tc2.xuntagged(imapclient.UntaggedExists(3))
tc2.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 1, UID: 3})
tc2.xcode(imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("3")})
// Messages that we cannot parse are marked as application/octet-stream. Perhaps
// the imap client knows how to deal with them.
tc2.transactf("ok", "uid fetch 2 body")
uid2 := imapclient.FetchUID(2)
xbs := imapclient.FetchBodystructure{
RespAttr: "BODY",
Body: imapclient.BodyTypeBasic{
@ -77,16 +86,50 @@ func TestAppend(t *testing.T) {
},
},
}
tc2.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, xbs}})
tc2.xuntagged(tc.untaggedFetch(2, 2, xbs))
tclimit := startArgs(t, false, false, true, true, "limit")
// Multiappend with two messages.
tc.transactf("ok", "noop") // Flush pending untagged responses.
tc.transactf("ok", "append inbox {6+}\r\ntest\r\n ~{6+}\r\ntost\r\n")
tc.xuntagged(imapclient.UntaggedExists(5))
tc.xcode(imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("4:5")})
// Cancelled with zero-length message.
tc.transactf("no", "append inbox {6+}\r\ntest\r\n {0+}\r\n")
tclimit := startArgs(t, uidonly, false, false, true, true, "limit")
defer tclimit.close()
tclimit.client.Login("limit@mox.example", "testtest")
tclimit.login("limit@mox.example", password0)
tclimit.client.Select("inbox")
// First message of 1 byte is within limits.
tclimit.transactf("ok", "append inbox (\\Seen Label1 $label2) \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
tclimit.xuntagged(imapclient.UntaggedExists(1))
// Second message would take account past limit.
tclimit.transactf("no", "append inbox (\\Seen Label1 $label2) \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
tclimit.xcode("OVERQUOTA")
tclimit.xcodeWord("OVERQUOTA")
// Empty mailbox.
if uidonly {
tclimit.transactf("ok", `uid store 1 flags (\deleted)`)
} else {
tclimit.transactf("ok", `store 1 flags (\deleted)`)
}
tclimit.transactf("ok", "expunge")
// Multiappend with first message within quota, and second message with sync
// literal causing quota error. Request should get error response immediately.
tclimit.transactf("no", "append inbox {1+}\r\nx {100000}")
tclimit.xcodeWord("OVERQUOTA")
// Again, but second message now with non-sync literal, which is fully consumed by server.
tclimit.client.WriteCommandf("", "append inbox {1+}\r\nx {4000+}")
buf := make([]byte, 4000, 4002)
for i := range buf {
buf[i] = 'x'
}
buf = append(buf, "\r\n"...)
_, err := tclimit.client.Write(buf)
tclimit.check(err, "write append message")
tclimit.response("no")
tclimit.xcodeWord("OVERQUOTA")
}

View File

@ -1,51 +1,74 @@
package imapserver
import (
"context"
"crypto/hmac"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"hash"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/text/secure/precis"
"github.com/mjl-/mox/imapclient"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/scram"
"github.com/mjl-/mox/store"
)
func TestAuthenticateLogin(t *testing.T) {
// NFD username and PRECIS-cleaned password.
tc := start(t, false)
tc.client.Login("mo\u0301x@mox.example", password1)
tc.close()
}
func TestAuthenticatePlain(t *testing.T) {
tc := start(t)
tc := start(t, false)
tc.transactf("no", "authenticate bogus ")
tc.transactf("bad", "authenticate plain not base64...")
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000baduser\u0000badpass")))
tc.xcode("AUTHENTICATIONFAILED")
tc.xcodeWord("AUTHENTICATIONFAILED")
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000badpass")))
tc.xcode("AUTHENTICATIONFAILED")
tc.xcodeWord("AUTHENTICATIONFAILED")
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl\u0000badpass"))) // Need email, not account.
tc.xcode("AUTHENTICATIONFAILED")
tc.xcodeWord("AUTHENTICATIONFAILED")
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000test")))
tc.xcode("AUTHENTICATIONFAILED")
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000testtesttest")))
tc.xcode("AUTHENTICATIONFAILED")
tc.xcodeWord("AUTHENTICATIONFAILED")
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000test"+password0)))
tc.xcodeWord("AUTHENTICATIONFAILED")
tc.transactf("bad", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000")))
tc.xcode("")
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("other\u0000mjl@mox.example\u0000testtest")))
tc.xcode("AUTHORIZATIONFAILED")
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000testtest")))
tc.xcode(nil)
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("other\u0000mjl@mox.example\u0000"+password0)))
tc.xcodeWord("AUTHORIZATIONFAILED")
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000"+password0)))
tc.close()
tc = start(t)
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("mjl@mox.example\u0000mjl@mox.example\u0000testtest")))
tc = start(t, false)
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("mjl@mox.example\u0000mjl@mox.example\u0000"+password0)))
tc.close()
tc = start(t)
tc.client.AuthenticatePlain("mjl@mox.example", "testtest")
// NFD username and PRECIS-cleaned password.
tc = start(t, false)
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("mo\u0301x@mox.example\u0000mo\u0301x@mox.example\u0000"+password1)))
tc.close()
tc = start(t)
tc = start(t, false)
tc.client.AuthenticatePlain("mjl@mox.example", password0)
tc.close()
tc = start(t, false)
defer tc.close()
tc.cmdf("", "authenticate plain")
@ -55,10 +78,32 @@ func TestAuthenticatePlain(t *testing.T) {
tc.cmdf("", "authenticate plain")
tc.readprefixline("+ ")
tc.writelinef("%s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000testtest")))
tc.writelinef("%s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000"+password0)))
tc.readstatus("ok")
}
func TestLoginDisabled(t *testing.T) {
tc := start(t, false)
defer tc.close()
acc, err := store.OpenAccount(pkglog, "disabled", false)
tcheck(t, err, "open account")
err = acc.SetPassword(pkglog, "test1234")
tcheck(t, err, "set password")
err = acc.Close()
tcheck(t, err, "close account")
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000disabled@mox.example\u0000test1234")))
tc.xcode(nil)
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000disabled@mox.example\u0000bogus")))
tc.xcodeWord("AUTHENTICATIONFAILED")
tc.transactf("no", "login disabled@mox.example test1234")
tc.xcode(nil)
tc.transactf("no", "login disabled@mox.example bogus")
tc.xcodeWord("AUTHENTICATIONFAILED")
}
func TestAuthenticateSCRAMSHA1(t *testing.T) {
testAuthenticateSCRAM(t, false, "SCRAM-SHA-1", sha1.New)
}
@ -76,8 +121,8 @@ func TestAuthenticateSCRAMSHA256PLUS(t *testing.T) {
}
func testAuthenticateSCRAM(t *testing.T, tls bool, method string, h func() hash.Hash) {
tc := startArgs(t, true, tls, true, true, "mjl")
tc.client.AuthenticateSCRAM(method, h, "mjl@mox.example", "testtest")
tc := startArgs(t, false, true, tls, true, true, "mjl")
tc.client.AuthenticateSCRAM(method, h, "mjl@mox.example", password0)
tc.close()
auth := func(status string, serverFinalError error, username, password string) {
@ -87,15 +132,11 @@ func testAuthenticateSCRAM(t *testing.T, tls bool, method string, h func() hash.
sc := scram.NewClient(h, username, "", noServerPlus, tc.client.TLSConnectionState())
clientFirst, err := sc.ClientFirst()
tc.check(err, "scram clientFirst")
tc.client.LastTag = "x001"
tc.writelinef("%s authenticate %s %s", tc.client.LastTag, method, base64.StdEncoding.EncodeToString([]byte(clientFirst)))
tc.client.WriteCommandf("", "authenticate %s %s", method, base64.StdEncoding.EncodeToString([]byte(clientFirst)))
xreadContinuation := func() []byte {
line, _, result, rerr := tc.client.ReadContinuation()
tc.check(rerr, "read continuation")
if result.Status != "" {
tc.t.Fatalf("expected continuation")
}
line, err := tc.client.ReadContinuation()
tcheck(t, err, "read continuation")
buf, err := base64.StdEncoding.DecodeString(line)
tc.check(err, "parsing base64 from remote")
return buf
@ -118,27 +159,31 @@ func testAuthenticateSCRAM(t *testing.T, tls bool, method string, h func() hash.
} else {
tc.writelinef("")
}
_, result, err := tc.client.Response()
resp, err := tc.client.ReadResponse()
tc.check(err, "read response")
if string(result.Status) != strings.ToUpper(status) {
tc.t.Fatalf("got status %q, expected %q", result.Status, strings.ToUpper(status))
if string(resp.Status) != strings.ToUpper(status) {
tc.t.Fatalf("got status %q, expected %q", resp.Status, strings.ToUpper(status))
}
}
tc = startArgs(t, true, tls, true, true, "mjl")
tc = startArgs(t, false, true, tls, true, true, "mjl")
auth("no", scram.ErrInvalidProof, "mjl@mox.example", "badpass")
auth("no", scram.ErrInvalidProof, "mjl@mox.example", "")
// todo: server aborts due to invalid username. we should probably make client continue with fake determinisitically generated salt and result in error in the end.
// auth("no", nil, "other@mox.example", "testtest")
// auth("no", nil, "other@mox.example", password0)
tc.transactf("no", "authenticate bogus ")
tc.transactf("bad", "authenticate %s not base64...", method)
tc.transactf("bad", "authenticate %s %s", method, base64.StdEncoding.EncodeToString([]byte("bad data")))
tc.transactf("no", "authenticate %s %s", method, base64.StdEncoding.EncodeToString([]byte("bad data")))
// NFD username, with PRECIS-cleaned password.
auth("ok", nil, "mo\u0301x@mox.example", password1)
tc.close()
}
func TestAuthenticateCRAMMD5(t *testing.T) {
tc := start(t)
tc := start(t, false)
tc.transactf("no", "authenticate bogus ")
tc.transactf("bad", "authenticate CRAM-MD5 not base64...")
@ -148,38 +193,190 @@ func TestAuthenticateCRAMMD5(t *testing.T) {
auth := func(status string, username, password string) {
t.Helper()
tc.client.LastTag = "x001"
tc.writelinef("%s authenticate CRAM-MD5", tc.client.LastTag)
tc.client.WriteCommandf("", "authenticate CRAM-MD5")
xreadContinuation := func() []byte {
line, _, result, rerr := tc.client.ReadContinuation()
tc.check(rerr, "read continuation")
if result.Status != "" {
tc.t.Fatalf("expected continuation")
}
line, err := tc.client.ReadContinuation()
tcheck(t, err, "read continuation")
buf, err := base64.StdEncoding.DecodeString(line)
tc.check(err, "parsing base64 from remote")
return buf
}
chal := xreadContinuation()
pw, err := precis.OpaqueString.String(password)
if err == nil {
password = pw
}
h := hmac.New(md5.New, []byte(password))
h.Write([]byte(chal))
resp := fmt.Sprintf("%s %x", username, h.Sum(nil))
tc.writelinef("%s", base64.StdEncoding.EncodeToString([]byte(resp)))
data := fmt.Sprintf("%s %x", username, h.Sum(nil))
tc.writelinef("%s", base64.StdEncoding.EncodeToString([]byte(data)))
_, result, err := tc.client.Response()
resp, err := tc.client.ReadResponse()
tc.check(err, "read response")
if string(result.Status) != strings.ToUpper(status) {
tc.t.Fatalf("got status %q, expected %q", result.Status, strings.ToUpper(status))
if string(resp.Status) != strings.ToUpper(status) {
tc.t.Fatalf("got status %q, expected %q", resp.Status, strings.ToUpper(status))
}
}
auth("no", "mjl@mox.example", "badpass")
auth("no", "mjl@mox.example", "")
auth("no", "other@mox.example", "testtest")
auth("no", "other@mox.example", password0)
auth("ok", "mjl@mox.example", "testtest")
auth("ok", "mjl@mox.example", password0)
tc.close()
// NFD username, with PRECIS-cleaned password.
tc = start(t, false)
auth("ok", "mo\u0301x@mox.example", password1)
tc.close()
}
func TestAuthenticateTLSClientCert(t *testing.T) {
tc := startArgsMore(t, false, true, true, nil, nil, true, true, "mjl", nil)
tc.transactf("no", "authenticate external ") // No TLS auth.
tc.close()
// Create a certificate, register its public key with account, and make a tls
// client config that sends the certificate.
clientCert0 := fakeCert(t, true)
clientConfig := tls.Config{
InsecureSkipVerify: true,
Certificates: []tls.Certificate{clientCert0},
}
tlspubkey, err := store.ParseTLSPublicKeyCert(clientCert0.Certificate[0])
tcheck(t, err, "parse certificate")
tlspubkey.Account = "mjl"
tlspubkey.LoginAddress = "mjl@mox.example"
tlspubkey.NoIMAPPreauth = true
addClientCert := func() error {
return store.TLSPublicKeyAdd(ctxbg, &tlspubkey)
}
// No preauth, explicit authenticate with TLS.
tc = startArgsMore(t, false, true, true, nil, &clientConfig, false, true, "mjl", addClientCert)
if tc.client.Preauth {
t.Fatalf("preauthentication while not configured for tls public key")
}
tc.transactf("ok", "authenticate external ")
tc.close()
// External with explicit username.
tc = startArgsMore(t, false, true, true, nil, &clientConfig, false, true, "mjl", addClientCert)
if tc.client.Preauth {
t.Fatalf("preauthentication while not configured for tls public key")
}
tc.transactf("ok", "authenticate external %s", base64.StdEncoding.EncodeToString([]byte("mjl@mox.example")))
tc.close()
// No preauth, also allow other mechanisms.
tc = startArgsMore(t, false, true, true, nil, &clientConfig, false, true, "mjl", addClientCert)
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000"+password0)))
tc.close()
// No preauth, also allow other username for same account.
tc = startArgsMore(t, false, true, true, nil, &clientConfig, false, true, "mjl", addClientCert)
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000móx@mox.example\u0000"+password0)))
tc.close()
// No preauth, other mechanism must be for same account.
acc, err := store.OpenAccount(pkglog, "other", false)
tcheck(t, err, "open account")
err = acc.SetPassword(pkglog, "test1234")
tcheck(t, err, "set password")
err = acc.Close()
tcheck(t, err, "close account")
tc = startArgsMore(t, false, true, true, nil, &clientConfig, false, true, "mjl", addClientCert)
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000other@mox.example\u0000test1234")))
tc.close()
// Starttls and external auth.
tc = startArgsMore(t, false, true, false, nil, &clientConfig, false, true, "mjl", addClientCert)
tc.client.StartTLS(&clientConfig)
tc.transactf("ok", "authenticate external =")
tc.close()
tlspubkey.NoIMAPPreauth = false
err = store.TLSPublicKeyUpdate(ctxbg, &tlspubkey)
tcheck(t, err, "update tls public key")
// With preauth, no authenticate command needed/allowed.
// Already set up tls session ticket cache, for next test.
serverConfig := tls.Config{
Certificates: []tls.Certificate{fakeCert(t, false)},
}
ctx, cancel := context.WithCancel(ctxbg)
defer cancel()
mox.StartTLSSessionTicketKeyRefresher(ctx, pkglog, &serverConfig)
clientConfig.ClientSessionCache = tls.NewLRUClientSessionCache(10)
tc = startArgsMore(t, false, true, true, &serverConfig, &clientConfig, false, true, "mjl", addClientCert)
if !tc.client.Preauth {
t.Fatalf("not preauthentication while configured for tls public key")
}
cs := tc.conn.(*tls.Conn).ConnectionState()
if cs.DidResume {
t.Fatalf("tls connection was resumed")
}
tc.transactf("no", "authenticate external ") // Not allowed, already in authenticated state.
tc.close()
// Authentication works with TLS resumption.
tc = startArgsMore(t, false, true, true, &serverConfig, &clientConfig, false, true, "mjl", addClientCert)
if !tc.client.Preauth {
t.Fatalf("not preauthentication while configured for tls public key")
}
cs = tc.conn.(*tls.Conn).ConnectionState()
if !cs.DidResume {
t.Fatalf("tls connection was not resumed")
}
// Check that operations that require an account work.
tc.client.Enable(imapclient.CapIMAP4rev2)
received, err := time.Parse(time.RFC3339, "2022-11-16T10:01:00+01:00")
tc.check(err, "parse time")
tc.client.Append("inbox", makeAppendTime(exampleMsg, received))
tc.client.Select("inbox")
tc.close()
// Authentication with unknown key should fail.
// todo: less duplication, change startArgs so this can be merged into it.
err = store.Close()
tcheck(t, err, "store close")
os.RemoveAll("../testdata/imap/data")
err = store.Init(ctxbg)
tcheck(t, err, "store init")
mox.ConfigStaticPath = filepath.FromSlash("../testdata/imap/mox.conf")
mox.MustLoadConfig(true, false)
switchStop := store.Switchboard()
defer switchStop()
serverConn, clientConn := net.Pipe()
defer clientConn.Close()
done := make(chan struct{})
defer func() { <-done }()
connCounter++
cid := connCounter
go func() {
defer serverConn.Close()
serve("test", cid, &serverConfig, serverConn, true, false, false, false, "")
close(done)
}()
clientConfig.ClientSessionCache = nil
clientConn = tls.Client(clientConn, &clientConfig)
// note: It's not enough to do a handshake and check if that was successful. If the
// client cert is not acceptable, we only learn after the handshake, when the first
// data messages are exchanged.
buf := make([]byte, 100)
_, err = clientConn.Read(buf)
if err == nil {
t.Fatalf("tls handshake with unknown client certificate succeeded")
}
if alert, ok := mox.AsTLSAlert(err); !ok || alert != 42 {
t.Fatalf("got err %#v, expected tls 'bad certificate' alert", err)
}
}

View File

@ -0,0 +1,82 @@
package imapserver
import (
"crypto/tls"
"encoding/base64"
"io"
mathrand "math/rand/v2"
"testing"
"time"
)
func TestCompress(t *testing.T) {
tc := start(t, false)
defer tc.close()
tc.login("mjl@mox.example", password0)
tc.transactf("bad", "compress")
tc.transactf("bad", "compress bogus ")
tc.transactf("no", "compress bogus")
tc.client.CompressDeflate()
tc.transactf("no", "compress deflate") // Cannot have multiple.
tc.xcodeWord("COMPRESSIONACTIVE")
tc.client.Select("inbox")
tc.transactf("ok", "append inbox (\\seen) {%d+}\r\n%s", len(exampleMsg), exampleMsg)
tc.transactf("ok", "noop")
tc.transactf("ok", "fetch 1 body.peek[1]")
}
func TestCompressStartTLS(t *testing.T) {
tc := start(t, false)
defer tc.close()
tc.client.StartTLS(&tls.Config{InsecureSkipVerify: true})
tc.login("mjl@mox.example", password0)
tc.client.CompressDeflate()
tc.client.Select("inbox")
tc.transactf("ok", "append inbox (\\seen) {%d+}\r\n%s", len(exampleMsg), exampleMsg)
tc.transactf("ok", "noop")
tc.transactf("ok", "fetch 1 body.peek[1]")
}
func TestCompressBreak(t *testing.T) {
// Close the client connection when the server is writing. That causes writes in
// the server to fail (panic), jumping out of the flate writer and leaving its
// state inconsistent. We must not call into the flate writer again because due to
// its broken internal state it may cause array out of bounds accesses.
tc := start(t, false)
defer tc.close()
msg := exampleMsg
// Add random data (so it is not compressible). Don't know why, but only
// reproducible with large writes. As if setting socket buffers had no effect.
buf := make([]byte, 64*1024)
_, err := io.ReadFull(mathrand.NewChaCha8([32]byte{}), buf)
tcheck(t, err, "read random")
text := base64.StdEncoding.EncodeToString(buf)
for len(text) > 0 {
n := min(76, len(text))
msg += text[:n] + "\r\n"
text = text[n:]
}
tc.login("mjl@mox.example", password0)
tc.client.CompressDeflate()
tc.client.Select("inbox")
tc.transactf("ok", "append inbox (\\seen) {%d+}\r\n%s", len(msg), msg)
tc.transactf("ok", "noop")
// Write request. Close connection instead of reading data. Write will panic,
// coming through flate writer leaving its state inconsistent. Server must not try
// to Flush/Write again on flate writer or it may panic.
tc.client.Writelinef("x fetch 1 body.peek[1]")
// Close client connection and prevent cleanup from closing the client again.
time.Sleep(time.Second / 10)
tc.client = nil
tc.conn.Close() // Simulate client disappearing.
}

View File

@ -10,39 +10,48 @@ import (
"github.com/mjl-/mox/imapclient"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/store"
"slices"
)
func TestCondstore(t *testing.T) {
testCondstoreQresync(t, false)
testCondstoreQresync(t, false, false)
}
func TestCondstoreUIDOnly(t *testing.T) {
testCondstoreQresync(t, false, true)
}
func TestQresync(t *testing.T) {
testCondstoreQresync(t, true)
testCondstoreQresync(t, true, false)
}
func testCondstoreQresync(t *testing.T, qresync bool) {
func TestQresyncUIDOnly(t *testing.T) {
testCondstoreQresync(t, true, true)
}
func testCondstoreQresync(t *testing.T, qresync, uidonly bool) {
defer mockUIDValidity()()
tc := start(t)
tc := start(t, uidonly)
defer tc.close()
// todo: check whether marking \seen will cause modseq to be returned in case of qresync.
// Check basic requirements of CONDSTORE.
capability := "Condstore"
capability := imapclient.CapCondstore
if qresync {
capability = "Qresync"
capability = imapclient.CapQresync
}
tc.client.Login("mjl@mox.example", "testtest")
tc.login("mjl@mox.example", password0)
tc.client.Enable(capability)
tc.transactf("ok", "Select inbox")
tc.xuntaggedOpt(false, imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "HIGHESTMODSEQ", CodeArg: imapclient.CodeHighestModSeq(1), More: "x"}})
tc.xuntaggedOpt(false, imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeHighestModSeq(2), Text: "x"})
// First some tests without any messages.
tc.transactf("ok", "Status inbox (Highestmodseq)")
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[string]int64{"HIGHESTMODSEQ": 1}})
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[imapclient.StatusAttr]int64{imapclient.StatusHighestModSeq: 2}})
// No messages, no matches.
tc.transactf("ok", "Uid Fetch 1:* (Flags) (Changedsince 12345)")
@ -56,6 +65,7 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
tc.transactf("ok", "Uid Fetch 1:* (Flags Modseq) (Changedsince 1)")
tc.xuntagged()
if !uidonly {
// Search with modseq search criteria.
tc.transactf("ok", "Search Modseq 0") // Zero is valid, matches all.
tc.xsearch()
@ -75,6 +85,7 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
// esearch
tc.transactf("ok", "Search Return (All) Modseq 123")
tc.xesearch(imapclient.UntaggedEsearch{})
}
// Now we add, delete, expunge, modify some message flags and check if the
// responses are correct. We check in both a condstore-enabled and one without that
@ -85,6 +96,8 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
// unmodified. Those messages have modseq 0 in the database. We use append for
// convenience, then adjust the records in the database.
// We have a workaround below to prevent triggering the consistency checker.
tc.account.SetSkipMessageModSeqZeroCheck(true)
defer tc.account.SetSkipMessageModSeqZeroCheck(false)
tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
@ -96,154 +109,171 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
err = tc.account.DB.Update(ctxbg, &store.SyncState{ID: 1, LastModSeq: 1})
tcheck(t, err, "resetting modseq state")
tc.client.Create("otherbox")
tc.client.Create("otherbox", nil)
// tc2 is a client without condstore, so no modseq responses.
tc2 := startNoSwitchboard(t)
defer tc2.close()
tc2.client.Login("mjl@mox.example", "testtest")
tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait()
tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox")
// tc3 is a client with condstore, so with modseq responses.
tc3 := startNoSwitchboard(t)
defer tc3.close()
tc3.client.Login("mjl@mox.example", "testtest")
tc3 := startNoSwitchboard(t, uidonly)
defer tc3.closeNoWait()
tc3.login("mjl@mox.example", password0)
tc3.client.Enable(capability)
tc3.client.Select("inbox")
var clientModseq int64 = 1 // We track the client-side modseq for inbox. Not a store.ModSeq.
var clientModseq int64 = 2 // We track the client-side modseq for inbox. Not a store.ModSeq.
// Add messages to: inbox, otherbox, inbox, inbox.
// We have these messages in order of modseq: 2+1 in inbox, 1 in otherbox, 2 in inbox.
// The original two in inbox appear to have modseq 1 (with 0 stored in the database).
// The ones we insert below will start with modseq 2. So we'll have modseq 1-5.
// Creation of otherbox got modseq 2.
// The ones we insert below will start with modseq 3. So we'll have messages with modseq 1 and 3-6.
tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
tc.xuntagged(imapclient.UntaggedExists(4))
tc.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 1, UID: 4})
tc.xcode(imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("4")})
tc.transactf("ok", "Append otherbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
tc.xuntagged()
tc.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 2, UID: 1})
tc.xcode(imapclient.CodeAppendUID{UIDValidity: 3, UIDs: xparseUIDRange("1")})
tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
tc.xuntagged(imapclient.UntaggedExists(5))
tc.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 1, UID: 5})
tc.xcode(imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("5")})
tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
tc.xuntagged(imapclient.UntaggedExists(6))
tc.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 1, UID: 6})
tc.xcode(imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("6")})
tc2.transactf("ok", "Noop")
noflags := imapclient.FetchFlags(nil)
tc2.xuntagged(
imapclient.UntaggedExists(6),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), noflags}},
imapclient.UntaggedFetch{Seq: 5, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags}},
imapclient.UntaggedFetch{Seq: 6, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags}},
tc2.untaggedFetch(4, 4, noflags),
tc2.untaggedFetch(5, 5, noflags),
tc2.untaggedFetch(6, 6, noflags),
)
tc3.transactf("ok", "Noop")
tc3.xuntagged(
imapclient.UntaggedExists(6),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), noflags, imapclient.FetchModSeq(clientModseq + 1)}},
imapclient.UntaggedFetch{Seq: 5, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(clientModseq + 3)}},
imapclient.UntaggedFetch{Seq: 6, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq + 4)}},
tc3.untaggedFetch(4, 4, noflags, imapclient.FetchModSeq(clientModseq+1)),
tc3.untaggedFetch(5, 5, noflags, imapclient.FetchModSeq(clientModseq+3)),
tc3.untaggedFetch(6, 6, noflags, imapclient.FetchModSeq(clientModseq+4)),
)
if !uidonly {
mox.SetPedantic(true)
tc.transactf("bad", `Fetch 1 Flags (Changedsince 0)`) // 0 not allowed in syntax.
mox.SetPedantic(false)
}
tc.transactf("ok", "Uid fetch 1 (Flags) (Changedsince 0)")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(clientModseq)}})
clientModseq += 4 // Four messages, over two mailboxes, modseq is per account.
tc.xuntagged(tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(1)))
// Check highestmodseq for mailboxes.
tc.transactf("ok", "Status inbox (highestmodseq)")
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[string]int64{"HIGHESTMODSEQ": clientModseq}})
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[imapclient.StatusAttr]int64{imapclient.StatusHighestModSeq: clientModseq + 4}})
tc.transactf("ok", "Status otherbox (highestmodseq)")
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "otherbox", Attrs: map[string]int64{"HIGHESTMODSEQ": 3}})
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "otherbox", Attrs: map[imapclient.StatusAttr]int64{imapclient.StatusHighestModSeq: clientModseq + 2}})
// Check highestmodseq when we select.
tc.transactf("ok", "Examine otherbox")
tc.xuntaggedOpt(false, imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "HIGHESTMODSEQ", CodeArg: imapclient.CodeHighestModSeq(3), More: "x"}})
tc.xuntaggedOpt(false, imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeHighestModSeq(clientModseq + 2), Text: "x"})
tc.transactf("ok", "Select inbox")
tc.xuntaggedOpt(false, imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "HIGHESTMODSEQ", CodeArg: imapclient.CodeHighestModSeq(clientModseq), More: "x"}})
tc.xuntaggedOpt(false, imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeHighestModSeq(clientModseq + 4), Text: "x"})
clientModseq += 4
if !uidonly {
// Check fetch modseq response and changedsince.
tc.transactf("ok", `Fetch 1 (Modseq)`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchModSeq(1)}})
tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchModSeq(1)))
}
// Without modseq attribute, even with condseq enabled, there is no modseq response.
// For QRESYNC, we must always send MODSEQ for UID FETCH commands, but not for FETCH commands. ../rfc/7162:1427
tc.transactf("ok", `Uid Fetch 1 Flags`)
if qresync {
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(1)}})
tc.xuntagged(tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(1)))
} else {
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags}})
tc.xuntagged(tc.untaggedFetch(1, 1, noflags))
}
tc.transactf("ok", `Fetch 1 Flags`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags}})
if !uidonly {
tc.transactf("ok", `Fetch 1 Flags`)
tc.xuntagged(tc.untaggedFetch(1, 1, noflags))
}
if !uidonly {
// When CHANGEDSINCE is present, MODSEQ is automatically added to the response.
// ../rfc/7162:871
// ../rfc/7162:877
tc.transactf("ok", `Fetch 1 Flags (Changedsince 1)`)
tc.xuntagged()
tc.transactf("ok", `Fetch 1,4 Flags (Changedsince 1)`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), noflags, imapclient.FetchModSeq(2)}})
tc.xuntagged(tc.untaggedFetch(4, 4, noflags, imapclient.FetchModSeq(3)))
tc.transactf("ok", `Fetch 2 Flags (Changedsince 2)`)
tc.xuntagged()
}
// store and uid store.
if !uidonly {
// unchangedsince 0 never passes the check. ../rfc/7162:640
tc.transactf("ok", `Store 1 (Unchangedsince 0) +Flags ()`)
tc.xcodeArg(imapclient.CodeModified(xparseNumSet("1")))
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(1)}})
tc.xcode(imapclient.CodeModified(xparseNumSet("1")))
tc.xuntagged(tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(1)))
}
// Modseq is 2 for first condstore-aware-appended message, so also no match.
tc.transactf("ok", `Uid Store 4 (Unchangedsince 1) +Flags ()`)
tc.xcodeArg(imapclient.CodeModified(xparseNumSet("4")))
tc.xcode(imapclient.CodeModified(xparseNumSet("4")))
if uidonly {
tc.transactf("ok", `Uid Store 1 (Unchangedsince 1) +Flags (label1)`)
} else {
// Modseq is 1 for original message.
tc.transactf("ok", `Store 1 (Unchangedsince 1) +Flags (label1)`)
tc.xcode("") // No MODIFIED.
}
tc.xcode(nil) // No MODIFIED.
clientModseq++
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags{"label1"}, imapclient.FetchModSeq(clientModseq)}})
tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{"label1"}, imapclient.FetchModSeq(clientModseq)))
tc2.transactf("ok", "Noop")
tc2.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags{"label1"}}},
tc2.untaggedFetch(1, 1, imapclient.FetchFlags{"label1"}),
)
tc3.transactf("ok", "Noop")
tc3.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags{"label1"}, imapclient.FetchModSeq(clientModseq)}},
tc3.untaggedFetch(1, 1, imapclient.FetchFlags{"label1"}, imapclient.FetchModSeq(clientModseq)),
)
// Modify same message twice. Check that second application doesn't fail due to
// modseq change made in the first application. ../rfc/7162:823
tc.transactf("ok", `Uid Store 1,1 (Unchangedsince %d) -Flags (label1)`, clientModseq)
clientModseq++
tc.xcode("") // No MODIFIED.
tc.xcode(nil) // No MODIFIED.
tc.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil), imapclient.FetchModSeq(clientModseq)}},
tc.untaggedFetch(1, 1, imapclient.FetchFlags(nil), imapclient.FetchModSeq(clientModseq)),
)
// We do broadcast the changes twice. Not great, but doesn't hurt. This isn't common.
tc2.transactf("ok", "Noop")
tc2.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil)}},
tc2.untaggedFetch(1, 1, imapclient.FetchFlags(nil)),
)
tc3.transactf("ok", "Noop")
tc3.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil), imapclient.FetchModSeq(clientModseq)}},
tc3.untaggedFetch(1, 1, imapclient.FetchFlags(nil), imapclient.FetchModSeq(clientModseq)),
)
if !uidonly {
// Modify without actually changing flags, there will be no new modseq and no broadcast.
tc.transactf("ok", `Store 1 (Unchangedsince %d) -Flags (label1)`, clientModseq)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil), imapclient.FetchModSeq(clientModseq)}})
tc.xcode("") // No MODIFIED.
tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags(nil), imapclient.FetchModSeq(clientModseq)))
tc.xcode(nil) // No MODIFIED.
tc2.transactf("ok", "Noop")
tc2.xuntagged()
tc3.transactf("ok", "Noop")
@ -252,17 +282,16 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
// search with modseq criteria and modseq in response
tc.transactf("ok", "Search Modseq %d", clientModseq)
tc.xsearchmodseq(clientModseq, 1)
}
tc.transactf("ok", "Uid Search Or Modseq %d Modseq %d", clientModseq, clientModseq)
tc.xsearchmodseq(clientModseq, 1)
if !uidonly {
// esearch
tc.transactf("ok", "Search Return (Min Max All) 1:* Modseq %d", clientModseq)
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 1, All: esearchall0("1"), ModSeq: clientModseq})
uint32ptr := func(v uint32) *uint32 {
return &v
}
tc.transactf("ok", "Search Return (Count) 1:* Modseq 0")
tc.xesearch(imapclient.UntaggedEsearch{Count: uint32ptr(6), ModSeq: clientModseq})
@ -276,20 +305,28 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
// modseq 0, the fourth was added with condstore-aware append.
tc.transactf("ok", `Store 3:4 +Flags (\Deleted)`)
clientModseq++
} else {
tc.transactf("ok", `Uid Store 3,4 +Flags (\Deleted)`)
clientModseq++
}
tc2.transactf("ok", "Noop")
tc3.transactf("ok", "Noop")
tc.transactf("ok", "Expunge")
clientModseq++
if qresync {
if qresync || uidonly {
tc.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("3:4")})
} else {
tc.xuntagged(imapclient.UntaggedExpunge(3), imapclient.UntaggedExpunge(3))
}
tc.xcodeArg(imapclient.CodeHighestModSeq(clientModseq))
tc.xcode(imapclient.CodeHighestModSeq(clientModseq))
tc2.transactf("ok", "Noop")
if uidonly {
tc2.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("3:4")})
} else {
tc2.xuntagged(imapclient.UntaggedExpunge(3), imapclient.UntaggedExpunge(3))
}
tc3.transactf("ok", "Noop")
if qresync {
if qresync || uidonly {
tc3.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("3:4")})
} else {
tc3.xuntagged(imapclient.UntaggedExpunge(3), imapclient.UntaggedExpunge(3))
@ -297,45 +334,49 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
// Again after expunge: status, select, conditional store/fetch/search
tc.transactf("ok", "Status inbox (Highestmodseq Messages Unseen Deleted)")
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[string]int64{"MESSAGES": 4, "UNSEEN": 4, "DELETED": 0, "HIGHESTMODSEQ": clientModseq}})
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[imapclient.StatusAttr]int64{imapclient.StatusMessages: 4, imapclient.StatusUnseen: 4, imapclient.StatusDeleted: 0, imapclient.StatusHighestModSeq: clientModseq}})
tc.transactf("ok", "Close")
tc.transactf("ok", "Select inbox")
tc.xuntaggedOpt(false,
imapclient.UntaggedExists(4),
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "HIGHESTMODSEQ", CodeArg: imapclient.CodeHighestModSeq(clientModseq), More: "x"}},
imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeHighestModSeq(clientModseq), Text: "x"},
)
if !uidonly {
tc.transactf("ok", `Fetch 1:* (Modseq)`)
tc.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchModSeq(7)}},
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), imapclient.FetchModSeq(1)}},
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), imapclient.FetchModSeq(4)}},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), imapclient.FetchModSeq(5)}},
tc.untaggedFetch(1, 1, imapclient.FetchModSeq(8)),
tc.untaggedFetch(2, 2, imapclient.FetchModSeq(1)),
tc.untaggedFetch(3, 5, imapclient.FetchModSeq(5)),
tc.untaggedFetch(4, 6, imapclient.FetchModSeq(6)),
)
}
// Expunged messages, with higher modseq, should not show up.
tc.transactf("ok", "Uid Fetch 1:* (flags) (Changedsince 7)")
tc.transactf("ok", "Uid Fetch 1:* (flags) (Changedsince 8)")
tc.xuntagged()
if !uidonly {
// search
tc.transactf("ok", "Search Modseq 7")
tc.xsearchmodseq(7, 1)
tc.transactf("ok", "Search Modseq 8")
tc.xsearchmodseq(8, 1)
tc.transactf("ok", "Search Modseq 9")
tc.xsearch()
// esearch
tc.transactf("ok", "Search Return (Min Max All) 1:* Modseq 7")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 1, All: esearchall0("1"), ModSeq: 7})
tc.transactf("ok", "Search Return (Min Max All) 1:* Modseq 8")
tc.xuntagged(imapclient.UntaggedEsearch{Correlator: tc.client.LastTag})
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 1, All: esearchall0("1"), ModSeq: 8})
tc.transactf("ok", "Search Return (Min Max All) 1:* Modseq 9")
tc.xuntagged(imapclient.UntaggedEsearch{Tag: tc.client.LastTag()})
}
// store, cannot modify expunged messages.
tc.transactf("ok", `Uid Store 3,4 (Unchangedsince %d) +Flags (label2)`, clientModseq)
tc.xuntagged()
tc.xcode("") // Not MODIFIED.
tc.xcode(nil) // Not MODIFIED.
tc.transactf("ok", `Uid Store 3,4 +Flags (label2)`)
tc.xuntagged()
tc.xcode("") // Not MODIFIED.
tc.xcode(nil) // Not MODIFIED.
// Check all condstore-enabling commands (and their syntax), ../rfc/7162:368
@ -349,22 +390,18 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
checkCondstoreEnabled := func(fn func(xtc *testconn)) {
t.Helper()
xtc := startNoSwitchboard(t)
xtc := startNoSwitchboard(t, uidonly)
// We have modified modseq & createseq to 0 above for testing that case. Don't
// trigger the consistency checker.
store.CheckConsistencyOnClose = false
defer func() {
xtc.close()
store.CheckConsistencyOnClose = true
}()
xtc.client.Login("mjl@mox.example", "testtest")
defer xtc.closeNoWait()
xtc.login("mjl@mox.example", password0)
fn(xtc)
tagcount++
label := fmt.Sprintf("l%d", tagcount)
tc.transactf("ok", "Store 4 Flags (%s)", label)
tc.transactf("ok", "Uid Store 6 Flags (%s)", label)
clientModseq++
xtc.transactf("ok", "Noop")
xtc.xuntagged(imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), imapclient.FetchFlags{label}, imapclient.FetchModSeq(clientModseq)}})
xtc.xuntagged(xtc.untaggedFetch(4, 6, imapclient.FetchFlags{label}, imapclient.FetchModSeq(clientModseq)))
}
// SELECT/EXAMINE with CONDSTORE parameter, ../rfc/7162:373
checkCondstoreEnabled(func(xtc *testconn) {
@ -381,25 +418,25 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
checkCondstoreEnabled(func(xtc *testconn) {
t.Helper()
xtc.transactf("ok", "Select inbox")
xtc.transactf("ok", "Fetch 4 (Modseq)")
xtc.transactf("ok", "Uid Fetch 6 (Modseq)")
})
// SEARCH with MODSEQ ../rfc/7162:377
checkCondstoreEnabled(func(xtc *testconn) {
t.Helper()
xtc.transactf("ok", "Select inbox")
xtc.transactf("ok", "Search 4 Modseq 1")
xtc.transactf("ok", "Uid Search Uid 6 Modseq 1")
})
// FETCH with CHANGEDSINCE ../rfc/7162:380
checkCondstoreEnabled(func(xtc *testconn) {
t.Helper()
xtc.transactf("ok", "Select inbox")
xtc.transactf("ok", "Fetch 4 (Flags) (Changedsince %d)", clientModseq)
xtc.transactf("ok", "Uid Fetch 6 (Flags) (Changedsince %d)", clientModseq)
})
// STORE with UNCHANGEDSINCE ../rfc/7162:382
checkCondstoreEnabled(func(xtc *testconn) {
t.Helper()
xtc.transactf("ok", "Select inbox")
xtc.transactf("ok", "Store 4 (Unchangedsince 0) Flags ()")
xtc.transactf("ok", "Uid Store 6 (Unchangedsince 0) Flags ()")
})
// ENABLE CONDSTORE ../rfc/7162:384
checkCondstoreEnabled(func(xtc *testconn) {
@ -413,11 +450,12 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
xtc.transactf("ok", "Enable Qresync")
xtc.transactf("ok", "Select inbox")
})
tc.transactf("ok", "Store 4 Flags ()")
clientModseq++
if qresync {
testQresync(t, tc, clientModseq)
tc.transactf("ok", "Uid Store 6 Flags ()")
clientModseq++
testQresync(t, tc, uidonly, clientModseq)
}
// Continue with some tests that further change the data.
@ -426,31 +464,31 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
tc.transactf("ok", "Select otherbox")
tc2.transactf("ok", "Noop")
tc3.transactf("ok", "Noop")
tc.transactf("ok", "Copy 1 inbox")
tc.transactf("ok", "Uid Copy 1 inbox")
clientModseq++
tc2.transactf("ok", "Noop")
tc3.transactf("ok", "Noop")
tc2.xuntagged(
imapclient.UntaggedExists(5),
imapclient.UntaggedFetch{Seq: 5, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(7), noflags}},
tc2.untaggedFetch(5, 7, noflags),
)
tc3.xuntagged(
imapclient.UntaggedExists(5),
imapclient.UntaggedFetch{Seq: 5, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(7), noflags, imapclient.FetchModSeq(clientModseq)}},
tc3.untaggedFetch(5, 7, noflags, imapclient.FetchModSeq(clientModseq)),
)
// Then we move some messages, and check if we get expunged/vanished in original
// and untagged fetch with modseq in destination mailbox.
// tc2o is a client without condstore, so no modseq responses.
tc2o := startNoSwitchboard(t)
defer tc2o.close()
tc2o.client.Login("mjl@mox.example", "testtest")
tc2o := startNoSwitchboard(t, uidonly)
defer tc2o.closeNoWait()
tc2o.login("mjl@mox.example", password0)
tc2o.client.Select("otherbox")
// tc3o is a client with condstore, so with modseq responses.
tc3o := startNoSwitchboard(t)
defer tc3o.close()
tc3o.client.Login("mjl@mox.example", "testtest")
tc3o := startNoSwitchboard(t, uidonly)
defer tc3o.closeNoWait()
tc3o.login("mjl@mox.example", password0)
tc3o.client.Enable(capability)
tc3o.client.Select("otherbox")
@ -459,15 +497,22 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
clientModseq++
if qresync {
tc.xuntaggedOpt(false, imapclient.UntaggedVanished{UIDs: xparseNumSet("2")})
tc.xcodeArg(imapclient.CodeHighestModSeq(clientModseq))
tc.xcode(imapclient.CodeHighestModSeq(clientModseq))
} else if uidonly {
tc.xuntaggedOpt(false, imapclient.UntaggedVanished{UIDs: xparseNumSet("2")})
tc.xcode(nil)
} else {
tc.xuntaggedOpt(false, imapclient.UntaggedExpunge(2))
tc.xcode("")
tc.xcode(nil)
}
tc2.transactf("ok", "Noop")
if uidonly {
tc2.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("2")})
} else {
tc2.xuntagged(imapclient.UntaggedExpunge(2))
}
tc3.transactf("ok", "Noop")
if qresync {
if qresync || uidonly {
tc3.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("2")})
} else {
tc3.xuntagged(imapclient.UntaggedExpunge(2))
@ -475,22 +520,17 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
tc2o.transactf("ok", "Noop")
tc2o.xuntagged(
imapclient.UntaggedExists(2),
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), noflags}},
tc2o.untaggedFetch(2, 2, noflags),
)
tc3o.transactf("ok", "Noop")
tc3o.xuntagged(
imapclient.UntaggedExists(2),
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), noflags, imapclient.FetchModSeq(clientModseq)}},
tc2o.untaggedFetch(2, 2, noflags, imapclient.FetchModSeq(clientModseq)),
)
// Restore valid modseq/createseq for the consistency checker.
_, err = bstore.QueryDB[store.Message](ctxbg, tc.account.DB).FilterEqual("CreateSeq", int64(0)).UpdateNonzero(store.Message{CreateSeq: 2})
tcheck(t, err, "updating modseq/createseq to valid values")
_, err = bstore.QueryDB[store.Message](ctxbg, tc.account.DB).FilterEqual("ModSeq", int64(0)).UpdateNonzero(store.Message{ModSeq: 2})
tcheck(t, err, "updating modseq/createseq to valid values")
tc2o.close()
tc2o.closeNoWait()
tc2o = nil
tc3o.close()
tc3o.closeNoWait()
tc3o = nil
// Then we rename inbox, which is special because it moves messages away instead of
@ -499,12 +539,19 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
tc.transactf("ok", "Rename inbox oldbox")
// todo spec: server doesn't respond with untagged responses, find rfc reference that says this is ok.
tc2.transactf("ok", "Noop")
if uidonly {
tc2.xuntagged(
imapclient.UntaggedList{Separator: '/', Mailbox: "oldbox"},
imapclient.UntaggedVanished{UIDs: xparseNumSet("1,5:7")},
)
} else {
tc2.xuntagged(
imapclient.UntaggedList{Separator: '/', Mailbox: "oldbox"},
imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1),
)
}
tc3.transactf("ok", "Noop")
if qresync {
if qresync || uidonly {
tc3.xuntagged(
imapclient.UntaggedList{Separator: '/', Mailbox: "oldbox"},
imapclient.UntaggedVanished{UIDs: xparseNumSet("1,5:7")},
@ -520,7 +567,7 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
tc.transactf("ok", "Delete otherbox")
}
func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
func testQresync(t *testing.T, tc *testconn, uidonly bool, clientModseq int64) {
// Vanished on non-uid fetch is not allowed. ../rfc/7162:1693
tc.transactf("bad", "fetch 1:* (Flags) (Changedsince 1 Vanished)")
@ -528,14 +575,11 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.transactf("bad", "Uid Fetch 1:* (Flags) (Vanished)")
// Vanished not allowed without first enabling qresync. ../rfc/7162:1697
xtc := startNoSwitchboard(t)
xtc.client.Login("mjl@mox.example", "testtest")
xtc := startNoSwitchboard(t, uidonly)
xtc.login("mjl@mox.example", password0)
xtc.transactf("ok", "Select inbox (Condstore)")
xtc.transactf("bad", "Uid Fetch 1:* (Flags) (Changedsince 1 Vanished)")
// Prevent triggering the consistency checker, we still have modseq/createseq at 0.
store.CheckConsistencyOnClose = false
xtc.close()
store.CheckConsistencyOnClose = true
xtc.closeNoWait()
xtc = nil
// Check that we get proper vanished responses.
@ -543,22 +587,20 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
noflags := imapclient.FetchFlags(nil)
tc.xuntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(4)}},
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(7)}},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}},
tc.untaggedFetch(3, 5, noflags, imapclient.FetchModSeq(5)),
tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(8)),
tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)
// select/examine with qresync parameters, including the various optional fields.
tc.transactf("ok", "Close")
// Must enable qresync explicitly before using. ../rfc/7162:1446
xtc = startNoSwitchboard(t)
xtc.client.Login("mjl@mox.example", "testtest")
xtc = startNoSwitchboard(t, uidonly)
xtc.login("mjl@mox.example", password0)
xtc.transactf("bad", "Select inbox (Qresync 1 0)")
// Prevent triggering the consistency checker, we still have modseq/createseq at 0.
store.CheckConsistencyOnClose = false
xtc.close()
store.CheckConsistencyOnClose = true
xtc.closeNoWait()
xtc = nil
tc.transactf("bad", "Select inbox (Qresync (0 1))") // Both args must be > 0.
@ -573,22 +615,26 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
flags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent l1 l2 l3 l4 l5 l6 l7 l8 label1`, " ")
permflags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent \*`, " ")
uflags := imapclient.UntaggedFlags(flags)
upermflags := imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "PERMANENTFLAGS", CodeArg: imapclient.CodeList{Code: "PERMANENTFLAGS", Args: permflags}, More: "x"}}
upermflags := imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodePermanentFlags(permflags), Text: "x"}
baseUntagged := []imapclient.Untagged{
uflags,
upermflags,
imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox"},
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UIDNEXT", CodeArg: imapclient.CodeUint{Code: "UIDNEXT", Num: 7}, More: "x"}},
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UIDVALIDITY", CodeArg: imapclient.CodeUint{Code: "UIDVALIDITY", Num: 1}, More: "x"}},
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UNSEEN", CodeArg: imapclient.CodeUint{Code: "UNSEEN", Num: 1}, More: "x"}},
imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeUIDNext(7), Text: "x"},
imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeUIDValidity(1), Text: "x"},
imapclient.UntaggedRecent(0),
imapclient.UntaggedExists(4),
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "HIGHESTMODSEQ", CodeArg: imapclient.CodeHighestModSeq(clientModseq), More: "x"}},
imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeHighestModSeq(clientModseq), Text: "x"},
}
if !uidonly {
baseUntagged = append(baseUntagged,
imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeUnseen(1), Text: "x"},
)
}
makeUntagged := func(l ...imapclient.Untagged) []imapclient.Untagged {
return append(append([]imapclient.Untagged{}, baseUntagged...), l...)
return slices.Concat(baseUntagged, l)
}
// uidvalidity 1, highest known modseq 1, sends full current state.
@ -596,9 +642,9 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.xuntagged(
makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(4)}},
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(7)}},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}},
tc.untaggedFetch(3, 5, noflags, imapclient.FetchModSeq(5)),
tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(8)),
tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)...,
)
@ -613,9 +659,9 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.xuntagged(
makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(4)}},
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(7)}},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}},
tc.untaggedFetch(3, 5, noflags, imapclient.FetchModSeq(5)),
tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(8)),
tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)...,
)
@ -624,9 +670,9 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.transactf("ok", "Select inbox (Qresync (1 1 1,2,5:6))")
tc.xuntagged(
makeUntagged(
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(4)}},
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(7)}},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}},
tc.untaggedFetch(3, 5, noflags, imapclient.FetchModSeq(5)),
tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(8)),
tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)...,
)
@ -635,7 +681,7 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.transactf("ok", "Select inbox (Qresync (1 1 5))")
tc.xuntagged(
makeUntagged(
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(4)}},
tc.untaggedFetch(3, 5, noflags, imapclient.FetchModSeq(5)),
)...,
)
@ -659,17 +705,23 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.transactf("ok", "Close")
tc.transactf("bad", "Select inbox (Qresync (1 1 1:6 (1 1,2)))") // Not same length.
tc.transactf("bad", "Select inbox (Qresync (1 1 1:6 (1,2 1)))") // Not same length.
if !uidonly {
tc.transactf("no", "Select inbox (Qresync (1 1 1:6 (1,2 1,1)))") // Not ascending.
}
tc.transactf("bad", "Select inbox (Qresync (1 1 1:6 (1:* 1:4)))") // Star not allowed.
if uidonly {
return
}
// With valid parameters, based on what a client would know at this stage.
tc.transactf("ok", "Select inbox (Qresync (1 1 1:6 (1,3,6 1,3,6)))")
tc.xuntagged(
makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(4)}},
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(7)}},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}},
tc.untaggedFetch(3, 5, noflags, imapclient.FetchModSeq(5)),
tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(8)),
tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)...,
)
@ -679,17 +731,17 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.xuntagged(
makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(7)}},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}},
tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(8)),
tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)...,
)
tc.transactf("ok", "Close")
tc.transactf("ok", "Select inbox (Qresync (1 8 (1,3,6 1,3,6)))")
tc.transactf("ok", "Select inbox (Qresync (1 9 (1,3,6 1,3,6)))")
tc.xuntagged(
makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}},
tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)...,
)
@ -697,12 +749,12 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
// since that time. Server detects this, sends full vanished history and continues
// working with modseq changed to 1 before the expunged uid.
tc.transactf("ok", "Close")
tc.transactf("ok", "Select inbox (Qresync (1 9 (1,3,6 1,3,6)))")
tc.transactf("ok", "Select inbox (Qresync (1 10 (1,3,6 1,3,6)))")
tc.xuntagged(
makeUntagged(
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "ALERT", More: "Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full synchronization recommended."}},
imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeWord("ALERT"), Text: "Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full synchronization recommended."},
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}},
tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)...,
)
@ -713,9 +765,130 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.transactf("ok", "Select inbox (Qresync (1 18 (1,3,6 1,3,6)))")
tc.xuntagged(
makeUntagged(
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "ALERT", More: "Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full synchronization recommended."}},
imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeWord("ALERT"), Text: "Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full synchronization recommended."},
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}},
tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)...,
)
}
func TestQresyncHistory(t *testing.T) {
testQresyncHistory(t, false)
}
func TestQresyncHistoryUIDOnly(t *testing.T) {
testQresyncHistory(t, true)
}
func testQresyncHistory(t *testing.T, uidonly bool) {
defer mockUIDValidity()()
tc := start(t, uidonly)
defer tc.close()
tc.login("mjl@mox.example", password0)
tc.client.Enable(imapclient.CapQresync)
tc.transactf("ok", "Append inbox {1+}\r\nx")
tc.transactf("ok", "Append inbox {1+}\r\nx") // modseq 6
tc.transactf("ok", "Append inbox {1+}\r\nx")
tc.transactf("ok", "Select inbox")
tc.client.UIDStoreFlagsAdd("1,3", true, `\Deleted`) // modseq 8
tc.client.Expunge() // modseq 9
tc.client.UIDStoreFlagsAdd("2", true, `\Seen`) // modseq 10
// We have UID 2, no more UID 1 and 3.
flags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent`, " ")
permflags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent \*`, " ")
uflags := imapclient.UntaggedFlags(flags)
upermflags := imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodePermanentFlags(permflags), Text: "x"}
baseUntagged := []imapclient.Untagged{
uflags,
upermflags,
imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox"},
imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeUIDNext(4), Text: "x"},
imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeUIDValidity(1), Text: "x"},
imapclient.UntaggedRecent(0),
imapclient.UntaggedExists(1),
imapclient.UntaggedResult{Status: imapclient.OK, Code: imapclient.CodeHighestModSeq(10), Text: "x"},
}
makeUntagged := func(l ...imapclient.Untagged) []imapclient.Untagged {
return slices.Concat(baseUntagged, l)
}
tc.transactf("ok", "Close")
tc.transactf("ok", "Select inbox (Qresync (1 1))")
tc.xuntagged(
makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("1,3")},
tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)),
)...,
)
err := tc.account.DB.Write(ctxbg, func(tx *bstore.Tx) error {
syncState := store.SyncState{ID: 1}
err := tx.Get(&syncState)
tcheck(t, err, "get syncstate")
syncState.HighestDeletedModSeq = 9
err = tx.Update(&syncState)
tcheck(t, err, "update syncstate")
q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{Expunged: true})
q.FilterLessEqual("ModSeq", syncState.HighestDeletedModSeq)
n, err := q.Delete()
tcheck(t, err, "delete history")
if n != 2 {
t.Fatalf("removed %d message history records, expected 2", n)
}
return nil
})
tcheck(t, err, "db write")
// We should still get VANISHED EARLIER for 1,3, even though we don't have history for it.
tc.transactf("ok", "Close")
tc.transactf("ok", "Select inbox (Qresync (1 1))")
tc.xuntagged(
makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("1,3")},
tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)),
)...,
)
// Similar with explicit UIDs.
tc.transactf("ok", "Close")
tc.transactf("ok", "Select inbox (Qresync (1 1 1:3))")
tc.xuntagged(
makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("1,3")},
tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)),
)...,
)
// Fetch with changedsince also returns VANISHED EARLIER when we don't have history anymore.
tc.transactf("ok", "uid fetch 1:3 flags (Changedsince 10)")
tc.xuntagged() // We still have history, nothing changed.
tc.transactf("ok", "uid fetch 1:3 flags (Changedsince 9)")
tc.xuntagged(tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)))
// Missing history, but no vanished requested.
tc.transactf("ok", "uid fetch 1:4 flags (Changedsince 1)")
tc.xuntagged(
tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)),
)
// Same, but with vanished requested.
tc.transactf("ok", "uid fetch 1:3 flags (Vanished Changedsince 10)")
tc.xuntagged() // We still have history, nothing changed.
tc.transactf("ok", "uid fetch 1:3 flags (Vanished Changedsince 9)")
tc.xuntagged(tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)))
// We return vanished for 1,3. Not for 4, since that is uidnext.
tc.transactf("ok", "uid fetch 1:4 flags (Vanished Changedsince 1)")
tc.xuntagged(
tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)),
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("1,3")},
)
}

View File

@ -7,17 +7,25 @@ import (
)
func TestCopy(t *testing.T) {
testCopy(t, false)
}
func TestCopyUIDOnly(t *testing.T) {
testCopy(t, true)
}
func testCopy(t *testing.T, uidonly bool) {
defer mockUIDValidity()()
tc := start(t)
tc := start(t, uidonly)
defer tc.close()
tc2 := startNoSwitchboard(t)
defer tc2.close()
tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait()
tc.client.Login("mjl@mox.example", "testtest")
tc.login("mjl@mox.example", password0)
tc.client.Select("inbox")
tc2.client.Login("mjl@mox.example", "testtest")
tc2.login("mjl@mox.example", password0)
tc2.client.Select("Trash")
tc.transactf("bad", "copy") // Missing params.
@ -25,48 +33,53 @@ func TestCopy(t *testing.T) {
tc.transactf("bad", "copy 1 inbox ") // Leftover.
// Seqs 1,2 and UIDs 3,4.
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
tc.client.StoreFlagsSet("1:2", true, `\Deleted`)
tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg))
tc.transactf("ok", `Uid Store 1:2 +Flags.Silent (\Deleted)`)
tc.client.Expunge()
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg))
if uidonly {
tc.transactf("ok", "uid copy 3:* Trash")
} else {
tc.transactf("no", "copy 1 nonexistent")
tc.xcode("TRYCREATE")
tc.xcodeWord("TRYCREATE")
tc.transactf("no", "copy 1 expungebox")
tc.xcodeWord("TRYCREATE")
tc.transactf("no", "copy 1 inbox") // Cannot copy to same mailbox.
tc2.transactf("ok", "noop") // Drain.
tc.transactf("ok", "copy 1:* Trash")
ptr := func(v uint32) *uint32 { return &v }
tc.xcodeArg(imapclient.CodeCopyUID{DestUIDValidity: 1, From: []imapclient.NumRange{{First: 3, Last: ptr(4)}}, To: []imapclient.NumRange{{First: 1, Last: ptr(2)}}})
tc.xcode(mustParseCode("COPYUID 1 3:4 1:2"))
}
tc2.transactf("ok", "noop")
tc2.xuntagged(
imapclient.UntaggedExists(2),
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil)}},
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), imapclient.FetchFlags(nil)}},
tc2.untaggedFetch(1, 1, imapclient.FetchFlags(nil)),
tc2.untaggedFetch(2, 2, imapclient.FetchFlags(nil)),
)
tc.transactf("no", "uid copy 1,2 Trash") // No match.
tc.transactf("ok", "uid copy 4,3 Trash")
tc.xcodeArg(imapclient.CodeCopyUID{DestUIDValidity: 1, From: []imapclient.NumRange{{First: 3, Last: ptr(4)}}, To: []imapclient.NumRange{{First: 3, Last: ptr(4)}}})
tc.xcode(mustParseCode("COPYUID 1 3:4 3:4"))
tc2.transactf("ok", "noop")
tc2.xuntagged(
imapclient.UntaggedExists(4),
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(3), imapclient.FetchFlags(nil)}},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), imapclient.FetchFlags(nil)}},
tc2.untaggedFetch(3, 3, imapclient.FetchFlags(nil)),
tc2.untaggedFetch(4, 4, imapclient.FetchFlags(nil)),
)
tclimit := startArgs(t, false, false, true, true, "limit")
tclimit := startArgs(t, uidonly, false, false, true, true, "limit")
defer tclimit.close()
tclimit.client.Login("limit@mox.example", "testtest")
tclimit.login("limit@mox.example", password0)
tclimit.client.Select("inbox")
// First message of 1 byte is within limits.
tclimit.transactf("ok", "append inbox (\\Seen Label1 $label2) \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
tclimit.xuntagged(imapclient.UntaggedExists(1))
// Second message would take account past limit.
tclimit.transactf("no", "copy 1:* Trash")
tclimit.xcode("OVERQUOTA")
tclimit.transactf("no", "uid copy 1:* Trash")
tclimit.xcodeWord("OVERQUOTA")
}

View File

@ -7,24 +7,42 @@ import (
)
func TestCreate(t *testing.T) {
tc := start(t)
testCreate(t, false)
}
func TestCreateUIDOnly(t *testing.T) {
testCreate(t, true)
}
func testCreate(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close()
tc2 := startNoSwitchboard(t)
defer tc2.close()
tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait()
tc.client.Login("mjl@mox.example", "testtest")
tc2.client.Login("mjl@mox.example", "testtest")
tc.login("mjl@mox.example", password0)
tc2.login("mjl@mox.example", password0)
tc.transactf("no", "create inbox") // Already exists and not allowed. ../rfc/9051:1913
tc.transactf("no", "create Inbox") // Idem.
// Don't allow names that can cause trouble when exporting to directories.
tc.transactf("no", "create .")
tc.transactf("no", "create ..")
tc.transactf("no", "create legit/..")
tc.transactf("ok", "create ...") // No special meaning.
// ../rfc/9051:1937
tc.transactf("ok", "create inbox/a/c")
tc.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "Inbox/a"}, imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "Inbox/a/c"})
tc2.transactf("ok", "noop")
tc2.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "Inbox/a"}, imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "Inbox/a/c"})
tc2.xuntagged(
imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "..."},
imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "Inbox/a"},
imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "Inbox/a/c"},
)
tc.transactf("no", "create inbox/a/c") // Exists.
@ -39,7 +57,7 @@ func TestCreate(t *testing.T) {
tc.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "mailbox"})
// OldName is only set for IMAP4rev2 or NOTIFY.
tc.client.Enable("imap4rev2")
tc.client.Enable(imapclient.CapIMAP4rev2)
tc.transactf("ok", "create mailbox2/")
tc.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "mailbox2", OldName: "mailbox2/"})
@ -72,8 +90,19 @@ func TestCreate(t *testing.T) {
tc.transactf("no", `create "#"`) // Leading hash not allowed.
tc.transactf("ok", `create "test#"`)
// Create with flags.
tc.transactf("no", `create "newwithflags" (use (\unknown))`)
tc.transactf("no", `create "newwithflags" (use (\all))`)
tc.transactf("ok", `create "newwithflags" (use (\archive))`)
tc.transactf("ok", "noop")
tc.xuntagged()
tc.transactf("ok", `create "newwithflags2" (use (\archive) use (\drafts \sent))`)
// UTF-7 checks are only for IMAP4 before rev2 and without UTF8=ACCEPT.
tc.transactf("ok", `create "&"`) // Interpreted as UTF-8, no UTF-7.
tc2.transactf("bad", `create "&"`) // Bad UTF-7.
tc2.transactf("ok", `create "&Jjo-"`) // ☺, valid UTF-7.
tc.transactf("ok", "create expungebox") // Existed in past.
tc.transactf("ok", "delete expungebox") // Gone again.
}

View File

@ -7,36 +7,45 @@ import (
)
func TestDelete(t *testing.T) {
tc := start(t)
testDelete(t, false)
}
func TestDeleteUIDOnly(t *testing.T) {
testDelete(t, false)
}
func testDelete(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close()
tc2 := startNoSwitchboard(t)
defer tc2.close()
tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait()
tc3 := startNoSwitchboard(t)
defer tc3.close()
tc3 := startNoSwitchboard(t, uidonly)
defer tc3.closeNoWait()
tc.client.Login("mjl@mox.example", "testtest")
tc2.client.Login("mjl@mox.example", "testtest")
tc3.client.Login("mjl@mox.example", "testtest")
tc.login("mjl@mox.example", password0)
tc2.login("mjl@mox.example", password0)
tc3.login("mjl@mox.example", password0)
tc.transactf("bad", "delete") // Missing mailbox.
tc.transactf("no", "delete inbox") // Cannot delete inbox.
tc.transactf("no", "delete nonexistent") // Cannot delete mailbox that does not exist.
tc.transactf("no", `delete "nonexistent"`) // Again, with quoted string syntax.
tc.transactf("no", `delete "expungebox"`) // Already removed.
tc.client.Subscribe("x")
tc.transactf("no", "delete x") // Subscription does not mean there is a mailbox that can be deleted.
tc.client.Create("a/b")
tc.client.Create("a/b", nil)
tc2.transactf("ok", "noop") // Drain changes.
tc3.transactf("ok", "noop")
// ../rfc/9051:2000
tc.transactf("no", "delete a") // Still has child.
tc.xcode("HASCHILDREN")
tc.xcodeWord("HASCHILDREN")
tc3.client.Enable("IMAP4rev2") // For \NonExistent support.
tc3.client.Enable(imapclient.CapIMAP4rev2) // For \NonExistent support.
tc.transactf("ok", "delete a/b")
tc2.transactf("ok", "noop")
tc2.xuntagged() // No IMAP4rev2, no \NonExistent.
@ -53,12 +62,12 @@ func TestDelete(t *testing.T) {
)
// Let's try again with a message present.
tc.client.Create("msgs")
tc.client.Append("msgs", nil, nil, []byte(exampleMsg))
tc.client.Create("msgs", nil)
tc.client.Append("msgs", makeAppend(exampleMsg))
tc.transactf("ok", "delete msgs")
// Delete for inbox/* is allowed.
tc.client.Create("inbox/a")
tc.client.Create("inbox/a", nil)
tc.transactf("ok", "delete inbox/a")
}

View File

@ -57,3 +57,9 @@ func xsyntaxErrorf(format string, args ...any) {
err := errors.New(errmsg)
panic(syntaxError{"", "", errmsg, err})
}
func xsyntaxCodeErrorf(code, format string, args ...any) {
errmsg := fmt.Sprintf(format, args...)
err := errors.New(errmsg)
panic(syntaxError{"", code, errmsg, err})
}

View File

@ -7,17 +7,25 @@ import (
)
func TestExpunge(t *testing.T) {
testExpunge(t, false)
}
func TestExpungeUIDOnly(t *testing.T) {
testExpunge(t, true)
}
func testExpunge(t *testing.T, uidonly bool) {
defer mockUIDValidity()()
tc := start(t)
tc := start(t, uidonly)
defer tc.close()
tc2 := startNoSwitchboard(t)
defer tc2.close()
tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait()
tc.client.Login("mjl@mox.example", "testtest")
tc.login("mjl@mox.example", password0)
tc.client.Select("inbox")
tc2.client.Login("mjl@mox.example", "testtest")
tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox")
tc.transactf("bad", "expunge leftover") // Leftover data.
@ -31,35 +39,43 @@ func TestExpunge(t *testing.T) {
tc.client.Unselect()
tc.client.Select("inbox")
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg))
tc.transactf("ok", "expunge") // Still nothing to remove.
tc.xuntagged()
tc.client.StoreFlagsAdd("1,3", true, `\Deleted`)
tc.transactf("ok", `uid store 1,3 +flags.silent \Deleted`)
tc2.transactf("ok", "noop") // Drain.
tc.transactf("ok", "expunge")
if uidonly {
tc.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("1,3")})
} else {
tc.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(2))
}
tc2.transactf("ok", "noop") // Drain.
if uidonly {
tc2.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("1,3")})
} else {
tc2.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(2))
}
tc.transactf("ok", "expunge") // Nothing to remove anymore.
tc.xuntagged()
// Only UID 2 is still left. We'll add 3 more. Getting us to UIDs 2,4,5,6.
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg))
tc.transactf("bad", "uid expunge") // Missing uid set.
tc.transactf("bad", "uid expunge 1 leftover") // Leftover data.
tc.transactf("bad", "uid expunge 1 leftover") // Leftover data.
tc.client.StoreFlagsAdd("1,2,4", true, `\Deleted`) // Marks UID 2,4,6 as deleted.
tc.transactf("ok", `uid store 2,4,6 +flags.silent \Deleted`)
tc.transactf("ok", "uid expunge 1")
tc.xuntagged() // No match.
@ -67,8 +83,16 @@ func TestExpunge(t *testing.T) {
tc2.transactf("ok", "noop") // Drain.
tc.transactf("ok", "uid expunge 4:6") // Removes UID 4,6 at seqs 2,4.
tc.xuntagged(imapclient.UntaggedExpunge(2), imapclient.UntaggedExpunge(3))
tc2.transactf("ok", "noop")
if uidonly {
tc.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("4,6")})
} else {
tc.xuntagged(imapclient.UntaggedExpunge(2), imapclient.UntaggedExpunge(3))
}
tc2.transactf("ok", "noop")
if uidonly {
tc2.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("4,6")})
} else {
tc2.xuntagged(imapclient.UntaggedExpunge(2), imapclient.UntaggedExpunge(3))
}
}

View File

@ -4,19 +4,20 @@ package imapserver
import (
"bytes"
"errors"
"context"
"fmt"
"io"
"log/slog"
"maps"
"mime"
"net/textproto"
"sort"
"slices"
"strings"
"golang.org/x/exp/maps"
"golang.org/x/exp/slog"
"github.com/mjl-/bstore"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/store"
@ -25,18 +26,20 @@ import (
// functions to handle fetch attribute requests are defined on fetchCmd.
type fetchCmd struct {
conn *conn
isUID bool // If this is a UID FETCH command.
rtx *bstore.Tx // Read-only transaction, kept open while processing all messages.
updateSeen []store.UID // To mark as seen after processing all messages. UID instead of message ID since moved messages keep their ID and insert a new ID in the original mailbox.
hasChangedSince bool // Whether CHANGEDSINCE was set. Enables MODSEQ in response.
expungeIssued bool // Set if any message has been expunged. Can happen for expunged messages.
// For message currently processing.
mailboxID int64
uid store.UID
tx *bstore.Tx // Writable tx, for storing message when first parsed as mime parts.
changes []store.Change // For updated Seen flag.
markSeen bool
needFlags bool
needModseq bool // Whether untagged responses needs modseq.
expungeIssued bool // Set if a message cannot be read. Can happen for expunged messages.
modseq store.ModSeq // Initialized on first change, for marking messages as seen.
isUID bool // If this is a UID FETCH command.
hasChangedSince bool // Whether CHANGEDSINCE was set. Enables MODSEQ in response.
deltaCounts store.MailboxCounts // By marking \Seen, the number of unread/unseen messages will go down. We update counts at the end.
newPreviews map[store.UID]string // Save with messages when done.
// Loaded when first needed, closed when message was processed.
m *store.Message // Message currently being processed.
@ -76,7 +79,7 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
p.xspace()
nums := p.xnumSet()
p.xspace()
atts := p.xfetchAtts(isUID)
atts := p.xfetchAtts()
var changedSince int64
var haveChangedSince bool
var vanished bool
@ -125,43 +128,67 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
}
p.xempty()
// We don't use c.account.WithRLock because we write to the client while reading messages.
// We get the rlock, then we check the mailbox, release the lock and read the messages.
// The db transaction still locks out any changes to the database...
c.account.RLock()
runlock := c.account.RUnlock
// Note: we call runlock in a closure because we replace it below.
defer func() {
runlock()
}()
var vanishedUIDs []store.UID
cmd := &fetchCmd{conn: c, mailboxID: c.mailboxID, isUID: isUID, hasChangedSince: haveChangedSince}
c.xdbwrite(func(tx *bstore.Tx) {
cmd.tx = tx
// Ensure the mailbox still exists.
mb := c.xmailboxID(tx, c.mailboxID)
// We only keep a wlock, only for initial checks and listing the uids. Then we
// unlock and work without a lock. So changes to the store can happen, and we need
// to deal with that. If we need to mark messages as seen, we do so after
// processing the fetch for all messages, in a single write transaction. We don't
// send untagged changes for those \seen flag changes before finishing this
// command, because we have to sequence all changes properly, and since we don't
// (want to) hold a wlock while processing messages (can be many!), other changes
// may have happened to the store. So instead, we'll silently mark messages as seen
// (the client should know this is happening anyway!), then broadcast the changes
// to everyone, including ourselves. A noop/idle command that may come next will
// return the \seen flag changes, in the correct order, with the correct modseq. We
// also cannot just apply pending changes while processing. It is not allowed at
// all for non-uid-fetch. It would also make life more complicated, e.g. we would
// perhaps have to check if newly added messages also match uid fetch set that was
// requested.
var uids []store.UID
var vanishedUIDs []store.UID
cmd := &fetchCmd{conn: c, isUID: isUID, hasChangedSince: haveChangedSince, mailboxID: c.mailboxID, newPreviews: map[store.UID]string{}}
defer func() {
if cmd.rtx == nil {
return
}
err := cmd.rtx.Rollback()
c.log.Check(err, "rollback rtx")
cmd.rtx = nil
}()
c.account.WithRLock(func() {
var err error
cmd.rtx, err = c.account.DB.Begin(context.TODO(), false)
cmd.xcheckf(err, "begin transaction")
// Ensure the mailbox still exists.
c.xmailboxID(cmd.rtx, c.mailboxID)
// With changedSince, the client is likely asking for a small set of changes. Use a
// database query to trim down the uids we need to look at.
// ../rfc/7162:871
// database query to trim down the uids we need to look at. We need to go through
// the database for "VANISHED (EARLIER)" anyway, to see UIDs that aren't in the
// session anymore. Vanished must be used with changedSince. ../rfc/7162:871
if changedSince > 0 {
q := bstore.QueryTx[store.Message](tx)
q := bstore.QueryTx[store.Message](cmd.rtx)
q.FilterNonzero(store.Message{MailboxID: c.mailboxID})
q.FilterGreater("ModSeq", store.ModSeqFromClient(changedSince))
if !vanished {
q.FilterEqual("Expunged", false)
}
err := q.ForEach(func(m store.Message) error {
if m.UID >= c.uidnext {
return nil
}
if isUID {
if nums.xcontainsKnownUID(m.UID, c.searchResult, func() store.UID { return c.uidnext - 1 }) {
if m.Expunged {
vanishedUIDs = append(vanishedUIDs, m.UID)
} else if isUID {
if nums.containsUID(m.UID, c.uids, c.searchResult) {
} else {
uids = append(uids, m.UID)
}
}
} else {
seq := c.sequence(m.UID)
if seq > 0 && nums.containsSeq(seq, c.uids, c.searchResult) {
@ -171,27 +198,28 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
return nil
})
xcheckf(err, "looking up messages with changedsince")
} else {
uids = c.xnumSetUIDs(isUID, nums)
// In case of vanished where we don't have the full history, we must send VANISHED
// for all uids matching nums. ../rfc/7162:1718
delModSeq, err := c.account.HighestDeletedModSeq(cmd.rtx)
xcheckf(err, "looking up highest deleted modseq")
if !vanished || changedSince >= delModSeq.Client() {
return
}
// Send vanished for all missing requested UIDs. ../rfc/7162:1718
if vanished {
delModSeq, err := c.account.HighestDeletedModSeq(tx)
xcheckf(err, "looking up highest deleted modseq")
if changedSince < delModSeq.Client() {
// First sort the uids we already found, for fast lookup.
sort.Slice(vanishedUIDs, func(i, j int) bool {
return vanishedUIDs[i] < vanishedUIDs[j]
})
// We'll iterate through all UIDs in the numset, and add anything that isn't
// already in uids and vanishedUIDs. First sort the uids we already found, for fast
// lookup. We'll gather new UIDs in more, so we don't break the binary search.
slices.Sort(vanishedUIDs)
slices.Sort(uids)
// We'll be gathering any more vanished uids in more.
more := map[store.UID]struct{}{}
more := map[store.UID]struct{}{} // We'll add them at the end.
checkVanished := func(uid store.UID) {
if uidSearch(c.uids, uid) <= 0 && uidSearch(vanishedUIDs, uid) <= 0 {
if uid < c.uidnext && uidSearch(uids, uid) <= 0 && uidSearch(vanishedUIDs, uid) <= 0 {
more[uid] = struct{}{}
}
}
// Now look through the requested uids. We may have a searchResult, handle it
// separately from a numset with potential stars, over which we can more easily
// iterate.
@ -200,7 +228,8 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
checkVanished(uid)
}
} else {
iter := nums.interpretStar(c.uids).newIter()
xlastUID := c.newCachedLastUID(cmd.rtx, c.mailboxID, func(xerr error) { xuserErrorf("%s", xerr) })
iter := nums.xinterpretStar(xlastUID).newIter()
for {
num, ok := iter.Next()
if !ok {
@ -209,77 +238,156 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
checkVanished(store.UID(num))
}
}
vanishedUIDs = append(vanishedUIDs, maps.Keys(more)...)
}
vanishedUIDs = slices.AppendSeq(vanishedUIDs, maps.Keys(more))
slices.Sort(vanishedUIDs)
} else {
uids = c.xnumSetEval(cmd.rtx, isUID, nums)
}
// Release the account lock.
runlock()
runlock = func() {} // Prevent defer from unlocking again.
})
// We are continuing without a lock, working off our snapshot of uids to process.
// First report all vanished UIDs. ../rfc/7162:1714
if len(vanishedUIDs) > 0 {
// Mention all vanished UIDs in compact numset form.
// ../rfc/7162:1985
sort.Slice(vanishedUIDs, func(i, j int) bool {
return vanishedUIDs[i] < vanishedUIDs[j]
})
// No hard limit on response sizes, but clients are recommended to not send more
// than 8k. We send a more conservative max 4k.
for _, s := range compactUIDSet(vanishedUIDs).Strings(4*1024 - 32) {
c.bwritelinef("* VANISHED (EARLIER) %s", s)
c.xbwritelinef("* VANISHED (EARLIER) %s", s)
}
}
for _, uid := range uids {
cmd.uid = uid
cmd.conn.log.Debug("processing uid", slog.Any("uid", uid))
cmd.process(atts)
defer cmd.msgclose() // In case of panic.
for _, cmd.uid = range uids {
cmd.conn.log.Debug("processing uid", slog.Any("uid", cmd.uid))
data, err := cmd.process(atts)
if err != nil {
cmd.conn.log.Infox("processing fetch attribute", err, slog.Any("uid", cmd.uid))
xuserErrorf("processing fetch attribute: %v", err)
}
var zeromc store.MailboxCounts
if cmd.deltaCounts != zeromc {
mb.Add(cmd.deltaCounts) // Unseen/Unread will be <= 0.
err := tx.Update(&mb)
xcheckf(err, "updating mailbox counts")
cmd.changes = append(cmd.changes, mb.ChangeCounts())
// No need to update account total message size.
// UIDFETCH in case of uidonly. ../rfc/9586:181
if c.uidonly {
fmt.Fprintf(cmd.conn.xbw, "* %d UIDFETCH ", cmd.uid)
} else {
fmt.Fprintf(cmd.conn.xbw, "* %d FETCH ", cmd.conn.xsequence(cmd.uid))
}
data.xwriteTo(cmd.conn, cmd.conn.xbw)
cmd.conn.xbw.Write([]byte("\r\n"))
cmd.msgclose()
}
// We've returned all data. Now we mark messages as seen in one go, in a new write
// transaction. We don't send untagged messages for the changes, since there may be
// unprocessed pending changes. Instead, we broadcast them to ourselve too, so a
// next noop/idle will return the flags to the client.
err := cmd.rtx.Rollback()
c.log.Check(err, "fetch read tx rollback")
cmd.rtx = nil
// ../rfc/9051:4432 We mark all messages that need it as seen at the end of the
// command, in a single transaction.
if len(cmd.updateSeen) > 0 || len(cmd.newPreviews) > 0 {
c.account.WithWLock(func() {
changes := make([]store.Change, 0, len(cmd.updateSeen)+1)
c.xdbwrite(func(wtx *bstore.Tx) {
mb, err := store.MailboxID(wtx, c.mailboxID)
if err == store.ErrMailboxExpunged {
xusercodeErrorf("NONEXISTENT", "mailbox has been expunged")
}
xcheckf(err, "get mailbox for updating counts after marking as seen")
var modseq store.ModSeq
for _, uid := range cmd.updateSeen {
m, err := bstore.QueryTx[store.Message](wtx).FilterNonzero(store.Message{MailboxID: c.mailboxID, UID: uid}).Get()
xcheckf(err, "get message")
if m.Expunged {
// Message has been deleted in the mean time.
cmd.expungeIssued = true
continue
}
if m.Seen {
// Message already marked as seen by another process.
continue
}
if modseq == 0 {
modseq, err = c.account.NextModSeq(wtx)
xcheckf(err, "get next mod seq")
}
oldFlags := m.Flags
mb.Sub(m.MailboxCounts())
m.Seen = true
mb.Add(m.MailboxCounts())
changes = append(changes, m.ChangeFlags(oldFlags, mb))
m.ModSeq = modseq
err = wtx.Update(&m)
xcheckf(err, "mark message as seen")
}
changes = append(changes, mb.ChangeCounts())
for uid, s := range cmd.newPreviews {
m, err := bstore.QueryTx[store.Message](wtx).FilterNonzero(store.Message{MailboxID: c.mailboxID, UID: uid}).Get()
xcheckf(err, "get message")
if m.Expunged {
// Message has been deleted in the mean time.
cmd.expungeIssued = true
continue
}
// note: we are not updating modseq.
m.Preview = &s
err = wtx.Update(&m)
xcheckf(err, "saving preview with message")
}
if modseq > 0 {
mb.ModSeq = modseq
err = wtx.Update(&mb)
xcheckf(err, "update mailbox with counts and modseq")
}
})
if len(cmd.changes) > 0 {
// Broadcast seen updates to other connections.
c.broadcast(cmd.changes)
// Broadcast these changes also to ourselves, so we'll send the updated flags, but
// in the correct order, after other changes.
store.BroadcastChanges(c.account, changes)
})
}
if cmd.expungeIssued {
// ../rfc/2180:343
c.writeresultf("%s NO [EXPUNGEISSUED] at least one message was expunged", tag)
// ../rfc/9051:5102
c.xwriteresultf("%s OK [EXPUNGEISSUED] at least one message was expunged", tag)
} else {
c.ok(tag, cmdstr)
}
}
func (cmd *fetchCmd) xmodseq() store.ModSeq {
if cmd.modseq == 0 {
var err error
cmd.modseq, err = cmd.conn.account.NextModSeq(cmd.tx)
cmd.xcheckf(err, "assigning next modseq")
}
return cmd.modseq
}
func (cmd *fetchCmd) xensureMessage() *store.Message {
if cmd.m != nil {
return cmd.m
}
q := bstore.QueryTx[store.Message](cmd.tx)
// We do not filter by Expunged, the message may have been deleted in other
// sessions, but not in ours.
q := bstore.QueryTx[store.Message](cmd.rtx)
q.FilterNonzero(store.Message{MailboxID: cmd.mailboxID, UID: cmd.uid})
q.FilterEqual("Expunged", false)
m, err := q.Get()
cmd.xcheckf(err, "get message for uid %d", cmd.uid)
cmd.m = &m
if m.Expunged {
cmd.expungeIssued = true
}
return cmd.m
}
@ -305,8 +413,9 @@ func (cmd *fetchCmd) xensureParsed() (*store.MsgReader, *message.Part) {
return cmd.msgr, cmd.part
}
func (cmd *fetchCmd) process(atts []fetchAtt) {
defer func() {
// msgclose must be called after processing a message (after having written/used
// its data), even in the case of a panic.
func (cmd *fetchCmd) msgclose() {
cmd.m = nil
cmd.part = nil
if cmd.msgr != nil {
@ -314,7 +423,10 @@ func (cmd *fetchCmd) process(atts []fetchAtt) {
cmd.conn.xsanity(err, "closing messagereader")
cmd.msgr = nil
}
}
func (cmd *fetchCmd) process(atts []fetchAtt) (rdata listspace, rerr error) {
defer func() {
x := recover()
if x == nil {
return
@ -322,16 +434,15 @@ func (cmd *fetchCmd) process(atts []fetchAtt) {
err, ok := x.(attrError)
if !ok {
panic(x)
} else if rerr == nil {
rerr = err
}
if errors.Is(err, bstore.ErrAbsent) {
cmd.expungeIssued = true
return
}
cmd.conn.log.Infox("processing fetch attribute", err, slog.Any("uid", cmd.uid))
xuserErrorf("processing fetch attribute: %v", err)
}()
data := listspace{bare("UID"), number(cmd.uid)}
var data listspace
if !cmd.conn.uidonly {
data = append(data, bare("UID"), number(cmd.uid))
}
cmd.markSeen = false
cmd.needFlags = false
@ -342,17 +453,7 @@ func (cmd *fetchCmd) process(atts []fetchAtt) {
}
if cmd.markSeen {
m := cmd.xensureMessage()
cmd.deltaCounts.Sub(m.MailboxCounts())
origFlags := m.Flags
m.Seen = true
cmd.deltaCounts.Add(m.MailboxCounts())
m.ModSeq = cmd.xmodseq()
err := cmd.tx.Update(m)
xcheckf(err, "marking message as seen")
// No need to update account total message size.
cmd.changes = append(cmd.changes, m.ChangeFlags(origFlags))
cmd.updateSeen = append(cmd.updateSeen, cmd.uid)
}
if cmd.needFlags {
@ -375,15 +476,12 @@ func (cmd *fetchCmd) process(atts []fetchAtt) {
// other mentioning of cases elsewhere in the RFC would be too superfluous.
//
// ../rfc/7162:877 ../rfc/7162:388 ../rfc/7162:909 ../rfc/7162:1426
if cmd.needModseq || cmd.hasChangedSince || cmd.conn.enabled[capQresync] && (cmd.isUID || cmd.markSeen) {
if cmd.needModseq || cmd.hasChangedSince || cmd.conn.enabled[capQresync] && cmd.isUID {
m := cmd.xensureMessage()
data = append(data, bare("MODSEQ"), listspace{bare(fmt.Sprintf("%d", m.ModSeq.Client()))})
}
// Write errors are turned into panics because we write through c.
fmt.Fprintf(cmd.conn.bw, "* %d FETCH ", cmd.conn.xsequence(cmd.uid))
data.writeTo(cmd.conn, cmd.conn.bw)
cmd.conn.bw.Write([]byte("\r\n"))
return data, nil
}
// result for one attribute. if processing fails, e.g. because data was requested
@ -392,8 +490,12 @@ func (cmd *fetchCmd) process(atts []fetchAtt) {
func (cmd *fetchCmd) xprocessAtt(a fetchAtt) []token {
switch a.field {
case "UID":
// Always present.
return nil
// Present by default without uidonly. For uidonly, we only add it when explicitly
// requested. ../rfc/9586:184
if cmd.conn.uidonly {
return []token{bare("UID"), number(cmd.uid)}
}
case "ENVELOPE":
_, part := cmd.xensureParsed()
envelope := xenvelope(part)
@ -404,9 +506,20 @@ func (cmd *fetchCmd) xprocessAtt(a fetchAtt) []token {
m := cmd.xensureMessage()
return []token{bare("INTERNALDATE"), dquote(m.Received.Format("_2-Jan-2006 15:04:05 -0700"))}
case "SAVEDATE":
m := cmd.xensureMessage()
// For messages in storage from before we implemented this extension, we don't have
// a savedate, and we return nil. This is normally meant to be per mailbox, but
// returning it per message should be fine. ../rfc/8514:191
var savedate token = nilt
if m.SaveDate != nil {
savedate = dquote(m.SaveDate.Format("_2-Jan-2006 15:04:05 -0700"))
}
return []token{bare("SAVEDATE"), savedate}
case "BODYSTRUCTURE":
_, part := cmd.xensureParsed()
bs := xbodystructure(part)
bs := xbodystructure(cmd.conn.log, part, true)
return []token{bare("BODYSTRUCTURE"), bs}
case "BODY":
@ -487,6 +600,37 @@ func (cmd *fetchCmd) xprocessAtt(a fetchAtt) []token {
case "MODSEQ":
cmd.needModseq = true
case "PREVIEW":
m := cmd.xensureMessage()
preview := m.Preview
// We ignore "lazy", generating the preview is fast enough.
if preview == nil {
// Get the preview. We'll save all generated previews in a single transaction at
// the end.
_, p := cmd.xensureParsed()
s, err := p.Preview(cmd.conn.log)
cmd.xcheckf(err, "generating preview")
preview = &s
cmd.newPreviews[m.UID] = s
}
var t token = nilt
if preview != nil {
s := *preview
// Limit to 200 characters (not bytes). ../rfc/8970:206
var n, o int
for o = range s {
n++
if n > 200 {
s = s[:o]
break
}
}
s = strings.TrimSpace(s)
t = string0(s)
}
return []token{bare(a.field), t}
default:
xserverErrorf("field %q not yet implemented", a.field)
}
@ -632,11 +776,15 @@ func (cmd *fetchCmd) xbinary(a fetchAtt) (string, token) {
cmd.xerrorf("binary only allowed on leaf parts, not multipart/* or message/rfc822 or message/global")
}
switch p.ContentTransferEncoding {
var cte string
if p.ContentTransferEncoding != nil {
cte = *p.ContentTransferEncoding
}
switch cte {
case "", "7BIT", "8BIT", "BINARY", "BASE64", "QUOTED-PRINTABLE":
default:
// ../rfc/9051:5913
xusercodeErrorf("UNKNOWN-CTE", "unknown Content-Transfer-Encoding %q", p.ContentTransferEncoding)
xusercodeErrorf("UNKNOWN-CTE", "unknown Content-Transfer-Encoding %q", cte)
}
r := p.Reader()
@ -660,7 +808,7 @@ func (cmd *fetchCmd) xbody(a fetchAtt) (string, token) {
if a.section == nil {
// Non-extensible form of BODYSTRUCTURE.
return a.field, xbodystructure(part)
return a.field, xbodystructure(cmd.conn.log, part, false)
}
cmd.peekOrSeen(a.peek)
@ -672,16 +820,13 @@ func (cmd *fetchCmd) xbody(a fetchAtt) (string, token) {
var offset int64
count := m.Size
if a.partial != nil {
offset = int64(a.partial.offset)
if offset > m.Size {
offset = m.Size
}
offset = min(int64(a.partial.offset), m.Size)
count = int64(a.partial.count)
if offset+count > m.Size {
count = m.Size - offset
}
}
return respField, readerSizeSyncliteral{&moxio.AtReader{R: msgr, Offset: offset}, count}
return respField, readerSizeSyncliteral{&moxio.AtReader{R: msgr, Offset: offset}, count, false}
}
sr := cmd.xsection(a.section, part)
@ -720,35 +865,40 @@ func (cmd *fetchCmd) xpartnumsDeref(nums []uint32, p *message.Part) *message.Par
}
func (cmd *fetchCmd) xsection(section *sectionSpec, p *message.Part) io.Reader {
// msgtext is not nil, i.e. HEADER* or TEXT (not MIME), for the top-level part (a message).
if section.part == nil {
return cmd.xsectionMsgtext(section.msgtext, p)
}
p = cmd.xpartnumsDeref(section.part.part, p)
// If there is no sectionMsgText, then this isn't for HEADER*, TEXT or MIME, i.e. a
// part body, e.g. "BODY[1]".
if section.part.text == nil {
return p.RawReader()
}
// ../rfc/9051:4535
if p.Message != nil {
// MIME is defined for all parts. Otherwise it's HEADER* or TEXT, which is only
// defined for parts that are messages. ../rfc/9051:4500 ../rfc/9051:4517
if !section.part.text.mime {
if p.Message == nil {
cmd.xerrorf("part is not a message, cannot request header* or text")
}
err := p.SetMessageReaderAt()
cmd.xcheckf(err, "preparing submessage")
p = p.Message
}
if !section.part.text.mime {
return cmd.xsectionMsgtext(section.part.text.msgtext, p)
}
// MIME header, see ../rfc/9051:4534 ../rfc/2045:1645
// MIME header, see ../rfc/9051:4514 ../rfc/2045:1652
h, err := io.ReadAll(p.HeaderReader())
cmd.xcheckf(err, "reading header")
matchesFields := func(line []byte) bool {
k := textproto.CanonicalMIMEHeaderKey(string(bytes.TrimRight(bytes.SplitN(line, []byte(":"), 2)[0], " \t")))
// Only add MIME-Version and additional CRLF for messages, not other parts. ../rfc/2045:1645 ../rfc/2045:1652
return (p.Envelope != nil && k == "Mime-Version") || strings.HasPrefix(k, "Content-")
return strings.HasPrefix(k, "Content-")
}
var match bool
@ -762,7 +912,7 @@ func (cmd *fetchCmd) xsection(section *sectionSpec, p *message.Part) io.Reader {
h = h[len(line):]
match = matchesFields(line) || match && (bytes.HasPrefix(line, []byte(" ")) || bytes.HasPrefix(line, []byte("\t")))
if match || len(line) == 2 {
if match {
hb.Write(line)
}
}
@ -770,11 +920,10 @@ func (cmd *fetchCmd) xsection(section *sectionSpec, p *message.Part) io.Reader {
}
func (cmd *fetchCmd) xsectionMsgtext(smt *sectionMsgtext, p *message.Part) io.Reader {
if smt.s == "HEADER" {
return p.HeaderReader()
}
switch smt.s {
case "HEADER":
return p.HeaderReader()
case "HEADER.FIELDS":
return cmd.xmodifiedHeader(p, smt.headers, false)
@ -782,8 +931,8 @@ func (cmd *fetchCmd) xsectionMsgtext(smt *sectionMsgtext, p *message.Part) io.Re
return cmd.xmodifiedHeader(p, smt.headers, true)
case "TEXT":
// It appears imap clients expect to get the body of the message, not a "text body"
// which sounds like it means a text/* part of a message. ../rfc/9051:4517
// TEXT the body (excluding headers) of a message, either the top-level message, or
// a nested as message/rfc822 or message/global. ../rfc/9051:4517
return p.RawReader()
}
panic(serverError{fmt.Errorf("missing case")})
@ -834,27 +983,24 @@ func (cmd *fetchCmd) sectionMsgtextName(smt *sectionMsgtext) string {
return s
}
func bodyFldParams(params map[string]string) token {
if len(params) == 0 {
func bodyFldParams(p *message.Part) token {
if len(p.ContentTypeParams) == 0 {
return nilt
}
params := make(listspace, 0, 2*len(p.ContentTypeParams))
// Ensure same ordering, easier for testing.
var keys []string
for k := range params {
keys = append(keys, k)
for _, k := range slices.Sorted(maps.Keys(p.ContentTypeParams)) {
v := p.ContentTypeParams[k]
params = append(params, string0(strings.ToUpper(k)), string0(v))
}
sort.Strings(keys)
l := make(listspace, 2*len(keys))
i := 0
for _, k := range keys {
l[i] = string0(strings.ToUpper(k))
l[i+1] = string0(params[k])
i += 2
}
return l
return params
}
func bodyFldEnc(s string) token {
func bodyFldEnc(cte *string) token {
var s string
if cte != nil {
s = *cte
}
up := strings.ToUpper(s)
switch up {
case "7BIT", "8BIT", "BINARY", "BASE64", "QUOTED-PRINTABLE":
@ -863,25 +1009,92 @@ func bodyFldEnc(s string) token {
return string0(s)
}
func bodyFldMd5(p *message.Part) token {
if p.ContentMD5 == nil {
return nilt
}
return string0(*p.ContentMD5)
}
func bodyFldDisp(log mlog.Log, p *message.Part) token {
if p.ContentDisposition == nil {
return nilt
}
// ../rfc/9051:5989
// mime.ParseMediaType recombines parameter value continuations like "title*0" and
// "title*1" into "title". ../rfc/2231:147
// And decodes character sets and removes language tags, like
// "title*0*=us-ascii'en'hello%20world. ../rfc/2231:210
disp, params, err := mime.ParseMediaType(*p.ContentDisposition)
if err != nil {
log.Debugx("parsing content-disposition, ignoring", err, slog.String("header", *p.ContentDisposition))
return nilt
} else if len(params) == 0 {
log.Debug("content-disposition has no parameters, ignoring", slog.String("header", *p.ContentDisposition))
return nilt
}
var fields listspace
for _, k := range slices.Sorted(maps.Keys(params)) {
fields = append(fields, string0(k), string0(params[k]))
}
return listspace{string0(disp), fields}
}
func bodyFldLang(p *message.Part) token {
// todo: ../rfc/3282:86 ../rfc/5646:218 we currently just split on comma and trim space, should properly parse header.
if p.ContentLanguage == nil {
return nilt
}
var l listspace
for _, s := range strings.Split(*p.ContentLanguage, ",") {
s = strings.TrimSpace(s)
if s == "" {
return string0(*p.ContentLanguage)
}
l = append(l, string0(s))
}
return l
}
func bodyFldLoc(p *message.Part) token {
if p.ContentLocation == nil {
return nilt
}
return string0(*p.ContentLocation)
}
// xbodystructure returns a "body".
// calls itself for multipart messages and message/{rfc822,global}.
func xbodystructure(p *message.Part) token {
func xbodystructure(log mlog.Log, p *message.Part, extensible bool) token {
if p.MediaType == "MULTIPART" {
// Multipart, ../rfc/9051:6355 ../rfc/9051:6411
var bodies concat
for i := range p.Parts {
bodies = append(bodies, xbodystructure(&p.Parts[i]))
bodies = append(bodies, xbodystructure(log, &p.Parts[i], extensible))
}
return listspace{bodies, string0(p.MediaSubType)}
r := listspace{bodies, string0(p.MediaSubType)}
// ../rfc/9051:6371
if extensible {
r = append(r,
bodyFldParams(p),
bodyFldDisp(log, p),
bodyFldLang(p),
bodyFldLoc(p),
)
}
return r
}
// ../rfc/9051:6355
var r listspace
if p.MediaType == "TEXT" {
// ../rfc/9051:6404 ../rfc/9051:6418
return listspace{
r = listspace{
dquote("TEXT"), string0(p.MediaSubType), // ../rfc/9051:6739
// ../rfc/9051:6376
bodyFldParams(p.ContentTypeParams), // ../rfc/9051:6401
bodyFldParams(p), // ../rfc/9051:6401
nilOrString(p.ContentID),
nilOrString(p.ContentDescription),
bodyFldEnc(p.ContentTransferEncoding),
@ -891,19 +1104,19 @@ func xbodystructure(p *message.Part) token {
} else if p.MediaType == "MESSAGE" && (p.MediaSubType == "RFC822" || p.MediaSubType == "GLOBAL") {
// ../rfc/9051:6415
// note: we don't have to prepare p.Message for reading, because we aren't going to read from it.
return listspace{
r = listspace{
dquote("MESSAGE"), dquote(p.MediaSubType), // ../rfc/9051:6732
// ../rfc/9051:6376
bodyFldParams(p.ContentTypeParams), // ../rfc/9051:6401
bodyFldParams(p), // ../rfc/9051:6401
nilOrString(p.ContentID),
nilOrString(p.ContentDescription),
bodyFldEnc(p.ContentTransferEncoding),
number(p.EndOffset - p.BodyOffset),
xenvelope(p.Message),
xbodystructure(p.Message),
xbodystructure(log, p.Message, extensible),
number(p.RawLineCount), // todo: or mp.RawLineCount?
}
}
} else {
var media token
switch p.MediaType {
case "APPLICATION", "AUDIO", "IMAGE", "FONT", "MESSAGE", "MODEL", "VIDEO":
@ -912,13 +1125,24 @@ func xbodystructure(p *message.Part) token {
media = string0(p.MediaType)
}
// ../rfc/9051:6404 ../rfc/9051:6407
return listspace{
r = listspace{
media, string0(p.MediaSubType), // ../rfc/9051:6723
// ../rfc/9051:6376
bodyFldParams(p.ContentTypeParams), // ../rfc/9051:6401
bodyFldParams(p), // ../rfc/9051:6401
nilOrString(p.ContentID),
nilOrString(p.ContentDescription),
bodyFldEnc(p.ContentTransferEncoding),
number(p.EndOffset - p.BodyOffset),
}
}
if extensible {
// ../rfc/9051:6366
r = append(r,
bodyFldMd5(p),
bodyFldDisp(log, p),
bodyFldLang(p),
bodyFldLoc(p),
)
}
return r
}

View File

@ -5,22 +5,33 @@ import (
"testing"
"time"
"github.com/mjl-/bstore"
"github.com/mjl-/mox/imapclient"
"github.com/mjl-/mox/store"
)
func TestFetch(t *testing.T) {
tc := start(t)
testFetch(t, false)
}
func TestFetchUIDOnly(t *testing.T) {
testFetch(t, true)
}
func testFetch(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close()
tc.client.Login("mjl@mox.example", "testtest")
tc.client.Enable("imap4rev2")
tc.login("mjl@mox.example", password0)
tc.client.Enable(imapclient.CapIMAP4rev2)
received, err := time.Parse(time.RFC3339, "2022-11-16T10:01:00+01:00")
tc.check(err, "parse time")
tc.client.Append("inbox", nil, &received, []byte(exampleMsg))
tc.client.Append("inbox", makeAppendTime(exampleMsg, received))
tc.client.Select("inbox")
uid1 := imapclient.FetchUID(1)
date1 := imapclient.FetchInternalDate("16-Nov-2022 10:01:00 +0100")
date1 := imapclient.FetchInternalDate{Date: received}
rfcsize1 := imapclient.FetchRFC822Size(len(exampleMsg))
env1 := imapclient.FetchEnvelope{
Date: "Mon, 7 Feb 1994 21:52:25 -0800",
@ -32,9 +43,7 @@ func TestFetch(t *testing.T) {
MessageID: "<B27397-0100000@Blurdybloop.example>",
}
noflags := imapclient.FetchFlags(nil)
bodyxstructure1 := imapclient.FetchBodystructure{
RespAttr: "BODY",
Body: imapclient.BodyTypeText{
bodystructbody1 := imapclient.BodyTypeText{
MediaType: "TEXT",
MediaSubtype: "PLAIN",
BodyFields: imapclient.BodyFields{
@ -42,10 +51,21 @@ func TestFetch(t *testing.T) {
Octets: 57,
},
Lines: 2,
},
}
bodyxstructure1 := imapclient.FetchBodystructure{
RespAttr: "BODY",
Body: bodystructbody1,
}
bodystructure1 := bodyxstructure1
bodystructure1.RespAttr = "BODYSTRUCTURE"
bodyext1 := imapclient.BodyExtension1Part{
Disposition: ptr((*string)(nil)),
DispositionParams: ptr([][2]string(nil)),
Language: ptr([]string(nil)),
Location: ptr((*string)(nil)),
}
bodystructbody1.Ext = &bodyext1
bodystructure1.Body = bodystructbody1
split := strings.SplitN(exampleMsg, "\r\n\r\n", 2)
exampleMsgHeader := split[0] + "\r\n\r\n"
@ -72,136 +92,188 @@ func TestFetch(t *testing.T) {
headerSplit := strings.SplitN(exampleMsgHeader, "\r\n", 2)
dateheader1 := imapclient.FetchBody{RespAttr: "BODY[HEADER.FIELDS (Date)]", Section: "HEADER.FIELDS (Date)", Body: headerSplit[0] + "\r\n\r\n"}
nodateheader1 := imapclient.FetchBody{RespAttr: "BODY[HEADER.FIELDS.NOT (Date)]", Section: "HEADER.FIELDS.NOT (Date)", Body: headerSplit[1]}
date1header1 := imapclient.FetchBody{RespAttr: "BODY[1.HEADER.FIELDS (Date)]", Section: "1.HEADER.FIELDS (Date)", Body: headerSplit[0] + "\r\n\r\n"}
nodate1header1 := imapclient.FetchBody{RespAttr: "BODY[1.HEADER.FIELDS.NOT (Date)]", Section: "1.HEADER.FIELDS.NOT (Date)", Body: headerSplit[1]}
mime1 := imapclient.FetchBody{RespAttr: "BODY[1.MIME]", Section: "1.MIME", Body: "MIME-Version: 1.0\r\nContent-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n\r\n"}
mime1 := imapclient.FetchBody{RespAttr: "BODY[1.MIME]", Section: "1.MIME", Body: "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n"}
flagsSeen := imapclient.FetchFlags{`\Seen`}
if !uidonly {
tc.transactf("ok", "fetch 1 all")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, date1, rfcsize1, env1, noflags}})
tc.xuntagged(tc.untaggedFetch(1, 1, date1, rfcsize1, env1, noflags))
tc.transactf("ok", "fetch 1 fast")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, date1, rfcsize1, noflags}})
tc.xuntagged(tc.untaggedFetch(1, 1, date1, rfcsize1, noflags))
tc.transactf("ok", "fetch 1 full")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, date1, rfcsize1, env1, bodyxstructure1, noflags}})
tc.xuntagged(tc.untaggedFetch(1, 1, date1, rfcsize1, env1, bodyxstructure1, noflags))
tc.transactf("ok", "fetch 1 flags")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, noflags}})
tc.xuntagged(tc.untaggedFetch(1, 1, noflags))
tc.transactf("ok", "fetch 1 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1))
// Should be returned unmodified, because there is no content-transfer-encoding.
tc.transactf("ok", "fetch 1 binary[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binary1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, binary1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.transactf("ok", "fetch 1 binary[1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypart1}}) // Seen flag not changed.
tc.xuntagged(tc.untaggedFetch(1, 1, binarypart1)) // Seen flag not changed.
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary[]<1.1>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartial1, flagsSeen}})
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "uid fetch 1 binary[]<1.1>")
tc.xuntagged(
tc.untaggedFetch(1, 1, binarypartial1, noflags),
tc.untaggedFetch(1, 1, flagsSeen), // For UID FETCH, we get the flags during the command.
)
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary[1]<1.1>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartpartial1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, binarypartpartial1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary[]<10000.10001>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binaryend1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, binaryend1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary[1]<10000.10001>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartend1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, binarypartend1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary.size[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarysize1}})
tc.xuntagged(tc.untaggedFetch(1, 1, binarysize1))
tc.transactf("ok", "fetch 1 binary.size[1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarysizepart1}})
tc.xuntagged(tc.untaggedFetch(1, 1, binarysizepart1))
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, body1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.transactf("ok", "fetch 1 body[]<1.2>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyoff1}}) // Already seen.
tc.xuntagged(tc.untaggedFetch(1, 1, bodyoff1)) // Already seen.
tc.transactf("ok", "noop")
tc.xuntagged() // Already seen.
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodypart1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodypart1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[1]<1.2>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1off1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, body1off1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[1]<100000.100000>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyend1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodyend1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[header]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyheader1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodyheader1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[text]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodytext1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodytext1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
// equivalent to body.peek[header], ../rfc/3501:3183
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 rfc822.header")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfcheader1}})
tc.xuntagged(tc.untaggedFetch(1, 1, rfcheader1))
// equivalent to body[text], ../rfc/3501:3199
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 rfc822.text")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfctext1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, rfctext1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
// equivalent to body[], ../rfc/3501:3179
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 rfc822")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfc1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, rfc1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
// With PEEK, we should not get the \Seen flag.
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body.peek[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1}})
tc.xuntagged(tc.untaggedFetch(1, 1, body1))
tc.transactf("ok", "fetch 1 binary.peek[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binary1}})
tc.xuntagged(tc.untaggedFetch(1, 1, binary1))
// HEADER.FIELDS and .NOT
tc.transactf("ok", "fetch 1 body.peek[header.fields (date)]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, dateheader1}})
tc.xuntagged(tc.untaggedFetch(1, 1, dateheader1))
tc.transactf("ok", "fetch 1 body.peek[header.fields.not (date)]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, nodateheader1}})
// For non-multipart messages, 1 means the whole message. ../rfc/9051:4481
tc.transactf("ok", "fetch 1 body.peek[1.header.fields (date)]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, date1header1}})
tc.transactf("ok", "fetch 1 body.peek[1.header.fields.not (date)]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, nodate1header1}})
tc.xuntagged(tc.untaggedFetch(1, 1, nodateheader1))
// For non-multipart messages, 1 means the whole message, but since it's not of
// type message/{rfc822,global} (a message), you can't get the message headers.
// ../rfc/9051:4481
tc.transactf("no", "fetch 1 body.peek[1.header]")
// MIME, part 1 for non-multipart messages is the message itself. ../rfc/9051:4481
tc.transactf("ok", "fetch 1 body.peek[1.mime]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, mime1}})
tc.xuntagged(tc.untaggedFetch(1, 1, mime1))
// Missing sequence number. ../rfc/9051:7018
tc.transactf("bad", "fetch 2 body[]")
tc.client.MSNStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1:1 body[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1, flagsSeen}})
tc.xuntagged(tc.untaggedFetch(1, 1, body1, noflags))
tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
} else {
tc.client.UIDStoreFlagsAdd("1", true, `\Seen`)
tc.transactf("ok", "noop")
}
// UID fetch
tc.transactf("ok", "uid fetch 1 body[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1}})
tc.xuntagged(tc.untaggedFetch(1, 1, body1))
// UID fetch
tc.transactf("ok", "uid fetch 2 body[]")
tc.xuntagged()
// Test some invalid syntax.
// SAVEDATE
tc.transactf("ok", "uid fetch 1 savedate")
// Fetch exact SaveDate we'll be expecting from server.
var saveDate time.Time
err = tc.account.DB.Read(ctxbg, func(tx *bstore.Tx) error {
inbox, err := tc.account.MailboxFind(tx, "Inbox")
tc.check(err, "get inbox")
if inbox == nil {
t.Fatalf("missing inbox")
}
m, err := bstore.QueryTx[store.Message](tx).FilterNonzero(store.Message{MailboxID: inbox.ID, UID: store.UID(uid1)}).Get()
tc.check(err, "get message")
if m.SaveDate == nil {
t.Fatalf("zero savedate for message")
}
saveDate = m.SaveDate.Truncate(time.Second)
return nil
})
tc.check(err, "get savedate")
tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchSaveDate{SaveDate: &saveDate}))
// Test some invalid syntax. Also invalid for uidonly.
tc.transactf("bad", "fetch")
tc.transactf("bad", "fetch ")
tc.transactf("bad", "fetch ")
@ -224,25 +296,38 @@ func TestFetch(t *testing.T) {
tc.transactf("bad", "fetch 1 body[header.fields.not ()]") // List must be non-empty.
tc.transactf("bad", "fetch 1 body[mime]") // MIME must be prefixed with a number. ../rfc/9051:4497
if !uidonly {
tc.transactf("no", "fetch 1 body[2]") // No such part.
}
// Add more complex message.
uid2 := imapclient.FetchUID(2)
bodystructure2 := imapclient.FetchBodystructure{
RespAttr: "BODYSTRUCTURE",
Body: imapclient.BodyTypeMpart{
Bodies: []any{
imapclient.BodyTypeBasic{BodyFields: imapclient.BodyFields{Octets: 275}},
imapclient.BodyTypeText{MediaType: "TEXT", MediaSubtype: "PLAIN", BodyFields: imapclient.BodyFields{Params: [][2]string{{"CHARSET", "US-ASCII"}}, Octets: 114}, Lines: 3},
imapclient.BodyTypeBasic{BodyFields: imapclient.BodyFields{Octets: 275}, Ext: &bodyext1},
imapclient.BodyTypeText{MediaType: "TEXT", MediaSubtype: "PLAIN", BodyFields: imapclient.BodyFields{Params: [][2]string{{"CHARSET", "US-ASCII"}}, Octets: 114}, Lines: 3, Ext: &bodyext1},
imapclient.BodyTypeMpart{
Bodies: []any{
imapclient.BodyTypeBasic{MediaType: "AUDIO", MediaSubtype: "BASIC", BodyFields: imapclient.BodyFields{CTE: "BASE64", Octets: 22}},
imapclient.BodyTypeBasic{MediaType: "IMAGE", MediaSubtype: "JPEG", BodyFields: imapclient.BodyFields{CTE: "BASE64"}},
imapclient.BodyTypeBasic{MediaType: "AUDIO", MediaSubtype: "BASIC", BodyFields: imapclient.BodyFields{CTE: "BASE64", Octets: 22}, Ext: &bodyext1},
imapclient.BodyTypeBasic{MediaType: "IMAGE", MediaSubtype: "JPEG", BodyFields: imapclient.BodyFields{CTE: "BASE64"}, Ext: &imapclient.BodyExtension1Part{
Disposition: ptr(ptr("inline")),
DispositionParams: ptr([][2]string{{"filename", "image.jpg"}}),
Language: ptr([]string(nil)),
Location: ptr((*string)(nil)),
}},
},
MediaSubtype: "PARALLEL",
Ext: &imapclient.BodyExtensionMpart{
Params: [][2]string{{"BOUNDARY", "unique-boundary-2"}},
Disposition: ptr((*string)(nil)), // Present but nil.
DispositionParams: ptr([][2]string(nil)),
Language: ptr([]string(nil)),
Location: ptr((*string)(nil)),
},
imapclient.BodyTypeText{MediaType: "TEXT", MediaSubtype: "ENRICHED", BodyFields: imapclient.BodyFields{Octets: 145}, Lines: 5},
},
imapclient.BodyTypeText{MediaType: "TEXT", MediaSubtype: "ENRICHED", BodyFields: imapclient.BodyFields{Octets: 145}, Lines: 5, Ext: &bodyext1},
imapclient.BodyTypeMsg{
MediaType: "MESSAGE",
MediaSubtype: "RFC822",
@ -255,49 +340,64 @@ func TestFetch(t *testing.T) {
To: []imapclient.Address{{Name: "mox", Adl: "", Mailbox: "info", Host: "mox.example"}},
},
Bodystructure: imapclient.BodyTypeText{
MediaType: "TEXT", MediaSubtype: "PLAIN", BodyFields: imapclient.BodyFields{Params: [][2]string{{"CHARSET", "ISO-8859-1"}}, CTE: "QUOTED-PRINTABLE", Octets: 51}, Lines: 1},
MediaType: "TEXT", MediaSubtype: "PLAIN", BodyFields: imapclient.BodyFields{Params: [][2]string{{"CHARSET", "ISO-8859-1"}}, CTE: "QUOTED-PRINTABLE", Octets: 51}, Lines: 1, Ext: &bodyext1},
Lines: 7,
Ext: &imapclient.BodyExtension1Part{
MD5: ptr("MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="),
Disposition: ptr((*string)(nil)),
DispositionParams: ptr([][2]string(nil)),
Language: ptr([]string{"en", "de"}),
Location: ptr(ptr("http://localhost")),
},
},
},
MediaSubtype: "MIXED",
Ext: &imapclient.BodyExtensionMpart{
Params: [][2]string{{"BOUNDARY", "unique-boundary-1"}},
Disposition: ptr((*string)(nil)), // Present but nil.
DispositionParams: ptr([][2]string(nil)),
Language: ptr([]string(nil)),
Location: ptr((*string)(nil)),
},
},
}
tc.client.Append("inbox", nil, &received, []byte(nestedMessage))
tc.transactf("ok", "fetch 2 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
tc.client.Append("inbox", makeAppendTime(nestedMessage, received))
tc.transactf("ok", "uid fetch 2 bodystructure")
tc.xuntagged(tc.untaggedFetch(2, 2, bodystructure2))
// Multiple responses.
if !uidonly {
tc.transactf("ok", "fetch 1:2 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "fetch 1,2 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "fetch 2:1 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "fetch 1:* bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "fetch *:1 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "fetch *:2 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
tc.xuntagged(tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "fetch * bodystructure") // Highest msgseq.
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
tc.xuntagged(tc.untaggedFetch(2, 2, bodystructure2))
}
tc.transactf("ok", "uid fetch 1:* bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "uid fetch 1:2 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "uid fetch 1,2 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "uid fetch 2:2 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
tc.xuntagged(tc.untaggedFetch(2, 2, bodystructure2))
// todo: read the bodies/headers of the parts, and of the nested message.
tc.transactf("ok", "fetch 2 body.peek[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[]", Body: nestedMessage}}})
tc.transactf("ok", "uid fetch 2 body.peek[]")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[]", Body: nestedMessage}))
part1 := tocrlf(` ... Some text appears here ...
@ -307,22 +407,22 @@ func TestFetch(t *testing.T) {
It could have been done with explicit typing as in the
next part.]
`)
tc.transactf("ok", "fetch 2 body.peek[1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[1]", Section: "1", Body: part1}}})
tc.transactf("ok", "uid fetch 2 body.peek[1]")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[1]", Section: "1", Body: part1}))
tc.transactf("no", "fetch 2 binary.peek[3]") // Only allowed on leaf parts, not multiparts.
tc.transactf("no", "fetch 2 binary.peek[5]") // Only allowed on leaf parts, not messages.
tc.transactf("no", "uid fetch 2 binary.peek[3]") // Only allowed on leaf parts, not multiparts.
tc.transactf("no", "uid fetch 2 binary.peek[5]") // Only allowed on leaf parts, not messages.
part31 := "aGVsbG8NCndvcmxkDQo=\r\n"
part31dec := "hello\r\nworld\r\n"
tc.transactf("ok", "fetch 2 binary.size[3.1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBinarySize{RespAttr: "BINARY.SIZE[3.1]", Parts: []uint32{3, 1}, Size: int64(len(part31dec))}}})
tc.transactf("ok", "uid fetch 2 binary.size[3.1]")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBinarySize{RespAttr: "BINARY.SIZE[3.1]", Parts: []uint32{3, 1}, Size: int64(len(part31dec))}))
tc.transactf("ok", "fetch 2 body.peek[3.1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[3.1]", Section: "3.1", Body: part31}}})
tc.transactf("ok", "uid fetch 2 body.peek[3.1]")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[3.1]", Section: "3.1", Body: part31}))
tc.transactf("ok", "fetch 2 binary.peek[3.1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBinary{RespAttr: "BINARY[3.1]", Parts: []uint32{3, 1}, Data: part31dec}}})
tc.transactf("ok", "uid fetch 2 binary.peek[3.1]")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBinary{RespAttr: "BINARY[3.1]", Parts: []uint32{3, 1}, Data: part31dec}))
part3 := tocrlf(`--unique-boundary-2
Content-Type: audio/basic
@ -333,19 +433,18 @@ aGVsbG8NCndvcmxkDQo=
--unique-boundary-2
Content-Type: image/jpeg
Content-Transfer-Encoding: base64
Content-Disposition: inline; filename=image.jpg
--unique-boundary-2--
`)
tc.transactf("ok", "fetch 2 body.peek[3]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[3]", Section: "3", Body: part3}}})
tc.transactf("ok", "uid fetch 2 body.peek[3]")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[3]", Section: "3", Body: part3}))
part2mime := tocrlf(`Content-type: text/plain; charset=US-ASCII
`)
tc.transactf("ok", "fetch 2 body.peek[2.mime]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[2.MIME]", Section: "2.MIME", Body: part2mime}}})
part2mime := "Content-type: text/plain; charset=US-ASCII\r\n"
tc.transactf("ok", "uid fetch 2 body.peek[2.mime]")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[2.MIME]", Section: "2.MIME", Body: part2mime}))
part5 := tocrlf(`From: info@mox.example
To: mox <info@mox.example>
@ -355,8 +454,8 @@ Content-Transfer-Encoding: Quoted-printable
... Additional text in ISO-8859-1 goes here ...
`)
tc.transactf("ok", "fetch 2 body.peek[5]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5]", Section: "5", Body: part5}}})
tc.transactf("ok", "uid fetch 2 body.peek[5]")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[5]", Section: "5", Body: part5}))
part5header := tocrlf(`From: info@mox.example
To: mox <info@mox.example>
@ -365,39 +464,101 @@ Content-Type: Text/plain; charset=ISO-8859-1
Content-Transfer-Encoding: Quoted-printable
`)
tc.transactf("ok", "fetch 2 body.peek[5.header]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.HEADER]", Section: "5.HEADER", Body: part5header}}})
part5mime := tocrlf(`Content-Type: Text/plain; charset=ISO-8859-1
Content-Transfer-Encoding: Quoted-printable
tc.transactf("ok", "uid fetch 2 body.peek[5.header]")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[5.HEADER]", Section: "5.HEADER", Body: part5header}))
part5mime := tocrlf(`Content-Type: message/rfc822
Content-MD5: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
Content-Language: en,de
Content-Location: http://localhost
`)
tc.transactf("ok", "fetch 2 body.peek[5.mime]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.MIME]", Section: "5.MIME", Body: part5mime}}})
tc.transactf("ok", "uid fetch 2 body.peek[5.mime]")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[5.MIME]", Section: "5.MIME", Body: part5mime}))
part5text := " ... Additional text in ISO-8859-1 goes here ...\r\n"
tc.transactf("ok", "fetch 2 body.peek[5.text]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.TEXT]", Section: "5.TEXT", Body: part5text}}})
tc.transactf("ok", "fetch 2 body.peek[5.1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.1]", Section: "5.1", Body: part5text}}})
tc.transactf("ok", "uid fetch 2 body.peek[5.text]")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[5.TEXT]", Section: "5.TEXT", Body: part5text}))
part5body := " ... Additional text in ISO-8859-1 goes here ...\r\n"
tc.transactf("ok", "uid fetch 2 body.peek[5.1]")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[5.1]", Section: "5.1", Body: part5body}))
// 5.1 is the part that is the sub message, but not as message/rfc822, but as part,
// so we cannot request a header.
tc.transactf("no", "uid fetch 2 body.peek[5.1.header]")
// In case of EXAMINE instead of SELECT, we should not be seeing any changed \Seen flags for non-peek commands.
tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.client.UIDStoreFlagsClear("1", true, `\Seen`)
tc.client.Unselect()
tc.client.Examine("inbox")
// Preview
preview := "Hello Joe, do you think we can meet at 3:30 tomorrow?"
tc.transactf("ok", "uid fetch 1 preview")
tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchPreview{Preview: &preview}))
tc.transactf("ok", "uid fetch 1 preview (lazy)")
tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchPreview{Preview: &preview}))
// On-demand preview and saving on first request.
err = tc.account.DB.Write(ctxbg, func(tx *bstore.Tx) error {
m := store.Message{ID: 1}
err := tx.Get(&m)
tcheck(t, err, "get message")
if m.UID != 1 {
t.Fatalf("uid %d instead of 1", m.UID)
}
m.Preview = nil
err = tx.Update(&m)
tcheck(t, err, "remove preview from message")
return nil
})
tcheck(t, err, "remove preview from database")
tc.transactf("ok", "uid fetch 1 preview")
tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchPreview{Preview: &preview}))
m := store.Message{ID: 1}
err = tc.account.DB.Get(ctxbg, &m)
tcheck(t, err, "get message")
if m.Preview == nil {
t.Fatalf("preview missing")
} else if *m.Preview != preview+"\n" {
t.Fatalf("got preview %q, expected %q", *m.Preview, preview+"\n")
}
tc.transactf("bad", "uid fetch 1 preview (bogus)")
// Start a second session. Use it to remove the message. First session should still
// be able to access the messages.
tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait()
tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox")
tc2.client.UIDStoreFlagsSet("1", true, `\Deleted`)
tc2.client.Expunge()
tc2.client.Logout()
if uidonly {
tc.transactf("ok", "uid fetch 1 binary[]")
tc.xuntagged(
tc.untaggedFetch(1, 1, imapclient.FetchFlags{`\Deleted`}),
imapclient.UntaggedVanished{UIDs: xparseNumSet("1")},
)
// Message no longer available in session.
} else {
tc.transactf("ok", "fetch 1 binary[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binary1}})
tc.xuntagged(tc.untaggedFetch(1, 1, binary1))
tc.transactf("ok", "fetch 1 body[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1}})
tc.xuntagged(tc.untaggedFetch(1, 1, body1))
tc.transactf("ok", "fetch 1 rfc822.text")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfctext1}})
tc.xuntagged(tc.untaggedFetch(1, 1, rfctext1))
tc.transactf("ok", "fetch 1 rfc822")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfc1}})
tc.xuntagged(tc.untaggedFetch(1, 1, rfc1))
}
tc.client.Logout()
}

View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"path/filepath"
@ -59,30 +60,11 @@ func FuzzServer(f *testing.F) {
f.Add(tag + cmd)
}
log := mlog.New("imapserver", nil)
mox.Context = ctxbg
mox.ConfigStaticPath = filepath.FromSlash("../testdata/imapserverfuzz/mox.conf")
mox.MustLoadConfig(true, false)
dataDir := mox.ConfigDirPath(mox.Conf.Static.DataDir)
os.RemoveAll(dataDir)
acc, err := store.OpenAccount(log, "mjl")
if err != nil {
f.Fatalf("open account: %v", err)
}
defer acc.Close()
err = acc.SetPassword(log, "testtest")
if err != nil {
f.Fatalf("set password: %v", err)
}
defer store.Switchboard()()
comm := store.RegisterComm(acc)
defer comm.Unregister()
var cid int64 = 1
var fl *os.File
if false {
var err error
fl, err = os.OpenFile("fuzz.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
f.Fatalf("fuzz log")
@ -96,6 +78,34 @@ func FuzzServer(f *testing.F) {
}
f.Fuzz(func(t *testing.T, s string) {
log := mlog.New("imapserver", nil)
mox.ConfigStaticPath = filepath.FromSlash("../testdata/imapserverfuzz/mox.conf")
mox.MustLoadConfig(true, false)
store.Close() // May not be open, we ignore error.
dataDir := mox.ConfigDirPath(mox.Conf.Static.DataDir)
os.RemoveAll(dataDir)
err := store.Init(ctxbg)
if err != nil {
t.Fatalf("store init: %v", err)
}
defer store.Switchboard()()
acc, err := store.OpenAccount(log, "mjl", false)
if err != nil {
t.Fatalf("open account: %v", err)
}
defer func() {
acc.Close()
acc.WaitClosed()
}()
err = acc.SetPassword(log, password0)
if err != nil {
t.Fatalf("set password: %v", err)
}
comm := store.RegisterComm(acc)
defer comm.Unregister()
run := func(cmds []string) {
limitersInit() // Reset rate limiters.
serverConn, clientConn := net.Pipe()
@ -118,28 +128,32 @@ func FuzzServer(f *testing.F) {
err := clientConn.SetDeadline(time.Now().Add(time.Second))
flog(err, "set client deadline")
client, _ := imapclient.New(clientConn, true)
opts := imapclient.Opts{
Logger: slog.Default().With("cid", mox.Cid()),
Error: func(err error) { panic(err) },
}
client, _ := imapclient.New(clientConn, &opts)
for _, cmd := range cmds {
client.Commandf("", "%s", cmd)
client.Response()
client.WriteCommandf("", "%s", cmd)
client.ReadResponse()
}
client.Commandf("", "%s", s)
client.Response()
client.WriteCommandf("", "%s", s)
client.ReadResponse()
}()
err = serverConn.SetDeadline(time.Now().Add(time.Second))
flog(err, "set server deadline")
serve("test", cid, nil, serverConn, false, true)
serve("test", cid, nil, serverConn, false, false, true, false, "")
cid++
}
// Each command brings the connection state one step further. We try the fuzzing
// input for each state.
run([]string{})
run([]string{"login mjl@mox.example testtest"})
run([]string{"login mjl@mox.example testtest", "select inbox"})
run([]string{`login mjl@mox.example "` + password0 + `"`})
run([]string{`login mjl@mox.example "` + password0 + `"`, "select inbox"})
xappend := fmt.Sprintf("append inbox () {%d+}\r\n%s", len(exampleMsg), exampleMsg)
run([]string{"login mjl@mox.example testtest", "select inbox", xappend})
run([]string{`login mjl@mox.example "` + password0 + `"`, "select inbox", xappend})
})
}

View File

@ -9,13 +9,14 @@ import (
)
func TestIdle(t *testing.T) {
tc1 := start(t)
tc1 := start(t, false)
defer tc1.close()
tc1.transactf("ok", "login mjl@mox.example testtest")
tc2 := startNoSwitchboard(t)
defer tc2.close()
tc2.transactf("ok", "login mjl@mox.example testtest")
tc2 := startNoSwitchboard(t, false)
defer tc2.closeNoWait()
tc1.login("mjl@mox.example", password0)
tc2.login("mjl@mox.example", password0)
tc1.transactf("ok", "select inbox")
tc2.transactf("ok", "select inbox")

View File

@ -1,12 +1,14 @@
package imapserver
import (
"bytes"
"fmt"
"path"
"sort"
"strings"
"github.com/mjl-/bstore"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/store"
)
@ -60,6 +62,7 @@ func (c *conn) cmdList(tag, cmd string, p *parser) {
isExtended = isExtended || isList
var retSubscribed, retChildren bool
var retStatusAttrs []string
var retMetadata []string
if p.take(" RETURN (") {
isExtended = true
// ../rfc/9051:6613 ../rfc/9051:6915 ../rfc/9051:7072 ../rfc/9051:6821 ../rfc/5819:95
@ -90,6 +93,18 @@ func (c *conn) cmdList(tag, cmd string, p *parser) {
retStatusAttrs = append(retStatusAttrs, p.xstatusAtt())
}
p.xtake(")")
case "METADATA":
// ../rfc/9590:167
p.xspace()
p.xtake("(")
for {
s := p.xmetadataKey()
retMetadata = append(retMetadata, s)
if !p.space() {
break
}
}
p.xtake(")")
default:
// ../rfc/9051:2398
xsyntaxErrorf("bad list return option %q", w)
@ -100,7 +115,7 @@ func (c *conn) cmdList(tag, cmd string, p *parser) {
if !isExtended && reference == "" && patterns[0] == "" {
// ../rfc/9051:2277 ../rfc/3501:2221
c.bwritelinef(`* LIST () "/" ""`)
c.xbwritelinef(`* LIST () "/" ""`)
c.ok(tag, cmd)
return
}
@ -117,6 +132,7 @@ func (c *conn) cmdList(tag, cmd string, p *parser) {
}
re := xmailboxPatternMatcher(reference, patterns)
var responseLines []string
var respMetadata []concatspace
c.account.WithRLock(func() {
c.xdbread(func(tx *bstore.Tx) {
@ -130,10 +146,11 @@ func (c *conn) cmdList(tag, cmd string, p *parser) {
var nameList []string
q := bstore.QueryTx[store.Mailbox](tx)
q.FilterEqual("Expunged", false)
err := q.ForEach(func(mb store.Mailbox) error {
names[mb.Name] = info{mailbox: &mb}
nameList = append(nameList, mb.Name)
for p := path.Dir(mb.Name); p != "."; p = path.Dir(p) {
for p := mox.ParentMailboxName(mb.Name); p != ""; p = mox.ParentMailboxName(p) {
hasChild[p] = true
}
return nil
@ -148,7 +165,7 @@ func (c *conn) cmdList(tag, cmd string, p *parser) {
if !ok {
nameList = append(nameList, sub.Name)
}
for p := path.Dir(sub.Name); p != "."; p = path.Dir(p) {
for p := mox.ParentMailboxName(sub.Name); p != ""; p = mox.ParentMailboxName(p) {
hasSubscribedChild[p] = true
}
return nil
@ -191,39 +208,64 @@ func (c *conn) cmdList(tag, cmd string, p *parser) {
flags = append(flags, bare(`\Subscribed`))
}
if info.mailbox != nil {
if info.mailbox.Archive {
flags = append(flags, bare(`\Archive`))
add := func(b bool, v string) {
if b {
flags = append(flags, bare(v))
}
if info.mailbox.Draft {
flags = append(flags, bare(`\Drafts`))
}
if info.mailbox.Junk {
flags = append(flags, bare(`\Junk`))
}
if info.mailbox.Sent {
flags = append(flags, bare(`\Sent`))
}
if info.mailbox.Trash {
flags = append(flags, bare(`\Trash`))
}
mb := info.mailbox
add(mb.Archive, `\Archive`)
add(mb.Draft, `\Drafts`)
add(mb.Junk, `\Junk`)
add(mb.Sent, `\Sent`)
add(mb.Trash, `\Trash`)
}
var extStr string
if extended != nil {
extStr = " " + extended.pack(c)
}
line := fmt.Sprintf(`* LIST %s "/" %s%s`, flags.pack(c), astring(c.encodeMailbox(name)).pack(c), extStr)
line := fmt.Sprintf(`* LIST %s "/" %s%s`, flags.pack(c), mailboxt(name).pack(c), extStr)
responseLines = append(responseLines, line)
if retStatusAttrs != nil && info.mailbox != nil {
responseLines = append(responseLines, c.xstatusLine(tx, *info.mailbox, retStatusAttrs))
}
// ../rfc/9590:101
if info.mailbox != nil && len(retMetadata) > 0 {
var meta listspace
for _, k := range retMetadata {
q := bstore.QueryTx[store.Annotation](tx)
q.FilterNonzero(store.Annotation{MailboxID: info.mailbox.ID, Key: k})
q.FilterEqual("Expunged", false)
a, err := q.Get()
var v token
if err == bstore.ErrAbsent {
v = nilt
} else {
xcheckf(err, "get annotation")
if a.IsString {
v = string0(string(a.Value))
} else {
v = readerSizeSyncliteral{bytes.NewReader(a.Value), int64(len(a.Value)), true}
}
}
meta = append(meta, astring(k), v)
}
line := concatspace{bare("*"), bare("METADATA"), mailboxt(info.mailbox.Name), meta}
respMetadata = append(respMetadata, line)
}
}
})
})
for _, line := range responseLines {
c.bwritelinef("%s", line)
c.xbwritelinef("%s", line)
}
for _, meta := range respMetadata {
meta.xwriteTo(c, c.xbw)
c.xbwritelinef("")
}
c.ok(tag, cmd)
}

View File

@ -8,10 +8,18 @@ import (
)
func TestListBasic(t *testing.T) {
tc := start(t)
testListBasic(t, false)
}
func TestListBasicUIDOnly(t *testing.T) {
testListBasic(t, true)
}
func testListBasic(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close()
tc.client.Login("mjl@mox.example", "testtest")
tc.login("mjl@mox.example", password0)
ulist := func(name string, flags ...string) imapclient.UntaggedList {
if len(flags) == 0 {
@ -26,6 +34,9 @@ func TestListBasic(t *testing.T) {
tc.last(tc.client.List("Inbox"))
tc.xuntagged(ulist("Inbox"))
tc.last(tc.client.List("expungebox"))
tc.xuntagged()
tc.last(tc.client.List("%"))
tc.xuntagged(ulist("Archive", `\Archive`), ulist("Drafts", `\Drafts`), ulist("Inbox"), ulist("Junk", `\Junk`), ulist("Sent", `\Sent`), ulist("Trash", `\Trash`))
@ -35,7 +46,7 @@ func TestListBasic(t *testing.T) {
tc.last(tc.client.List("A*"))
tc.xuntagged(ulist("Archive", `\Archive`))
tc.client.Create("Inbox/todo")
tc.client.Create("Inbox/todo", nil)
tc.last(tc.client.List("Inbox*"))
tc.xuntagged(ulist("Inbox"), ulist("Inbox/todo"))
@ -56,12 +67,20 @@ func TestListBasic(t *testing.T) {
}
func TestListExtended(t *testing.T) {
testListExtended(t, false)
}
func TestListExtendedUIDOnly(t *testing.T) {
testListExtended(t, true)
}
func testListExtended(t *testing.T, uidonly bool) {
defer mockUIDValidity()()
tc := start(t)
tc := start(t, uidonly)
defer tc.close()
tc.client.Login("mjl@mox.example", "testtest")
tc.login("mjl@mox.example", password0)
ulist := func(name string, flags ...string) imapclient.UntaggedList {
if len(flags) == 0 {
@ -78,7 +97,7 @@ func TestListExtended(t *testing.T) {
for _, name := range store.DefaultInitialMailboxes.Regular {
uidvals[name] = 1
}
var uidvalnext uint32 = 2
var uidvalnext uint32 = 3
uidval := func(name string) uint32 {
v, ok := uidvals[name]
if !ok {
@ -90,15 +109,15 @@ func TestListExtended(t *testing.T) {
}
ustatus := func(name string) imapclient.UntaggedStatus {
attrs := map[string]int64{
"MESSAGES": 0,
"UIDNEXT": 1,
"UIDVALIDITY": int64(uidval(name)),
"UNSEEN": 0,
"DELETED": 0,
"SIZE": 0,
"RECENT": 0,
"APPENDLIMIT": 0,
attrs := map[imapclient.StatusAttr]int64{
imapclient.StatusMessages: 0,
imapclient.StatusUIDNext: 1,
imapclient.StatusUIDValidity: int64(uidval(name)),
imapclient.StatusUnseen: 0,
imapclient.StatusDeleted: 0,
imapclient.StatusSize: 0,
imapclient.StatusRecent: 0,
imapclient.StatusAppendLimit: 0,
}
return imapclient.UntaggedStatus{Mailbox: name, Attrs: attrs}
}
@ -146,7 +165,7 @@ func TestListExtended(t *testing.T) {
tc.last(tc.client.ListFull(false, "A*", "Junk"))
tc.xuntagged(xlist("Archive", Farchive), ustatus("Archive"), xlist("Junk", Fjunk), ustatus("Junk"))
tc.client.Create("Inbox/todo")
tc.client.Create("Inbox/todo", nil)
tc.last(tc.client.ListFull(false, "Inbox*"))
tc.xuntagged(ulist("Inbox", Fhaschildren, Fsubscribed), ustatus("Inbox"), xlist("Inbox/todo"), ustatus("Inbox/todo"))
@ -204,7 +223,7 @@ func TestListExtended(t *testing.T) {
tc.transactf("ok", `list (remote) "inbox" "a"`)
tc.xuntagged()
tc.client.Create("inbox/a")
tc.client.Create("inbox/a", nil)
tc.transactf("ok", `list (remote) "inbox" "a"`)
tc.xuntagged(ulist("Inbox/a"))
@ -216,4 +235,21 @@ func TestListExtended(t *testing.T) {
tc.transactf("bad", `list (recursivematch remote) "" "*"`) // "remote" is not a base selection option.
tc.transactf("bad", `list (unknown) "" "*"`) // Unknown selection options must result in BAD.
tc.transactf("bad", `list () "" "*" return (unknown)`) // Unknown return options must result in BAD.
// Return metadata.
tc.transactf("ok", `setmetadata inbox (/private/comment "y")`)
tc.transactf("ok", `list () "" ("inbox") return (metadata (/private/comment /shared/comment))`)
tc.xuntagged(
ulist("Inbox"),
imapclient.UntaggedMetadataAnnotations{
Mailbox: "Inbox",
Annotations: []imapclient.Annotation{
{Key: "/private/comment", IsString: true, Value: []byte("y")},
{Key: "/shared/comment"},
},
},
)
tc.transactf("bad", `list () "" ("inbox") return (metadata ())`) // Metadata list must be non-empty.
tc.transactf("bad", `list () "" ("inbox") return (metadata (/shared/comment "/private/comment" ))`) // Extra space.
}

View File

@ -7,10 +7,18 @@ import (
)
func TestLsub(t *testing.T) {
tc := start(t)
testLsub(t, false)
}
func TestLsubUIDOnly(t *testing.T) {
testLsub(t, true)
}
func testLsub(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close()
tc.client.Login("mjl@mox.example", "testtest")
tc.login("mjl@mox.example", password0)
tc.transactf("bad", "lsub") // Missing params.
tc.transactf("bad", `lsub ""`) // Missing param.
@ -19,6 +27,9 @@ func TestLsub(t *testing.T) {
tc.transactf("ok", `lsub "" x*`)
tc.xuntagged()
tc.transactf("ok", `lsub "" expungebox`)
tc.xuntagged(imapclient.UntaggedLsub{Separator: '/', Mailbox: "expungebox"})
tc.transactf("ok", "create a/b/c")
tc.transactf("ok", `lsub "" a/*`)
tc.xuntagged(imapclient.UntaggedLsub{Separator: '/', Mailbox: "a/b"}, imapclient.UntaggedLsub{Separator: '/', Mailbox: "a/b/c"})

Some files were not shown because too many files have changed in this diff Show More