commonmeta-schema 1.0rc2.dev4__tar.gz → 1.0rc2.dev6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/PKG-INFO +1 -1
  2. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/jsonfeed_blog_post.json +14 -8
  3. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/schemaorg_gtex.json +30 -3
  4. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/pyproject.toml +1 -1
  5. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/.gitignore +0 -0
  6. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/LICENSE +0 -0
  7. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/README.md +0 -0
  8. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/bibtex/crossref.bib +0 -0
  9. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/bibtex/journal_article.bib +0 -0
  10. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/bibtex/pure.bib +0 -0
  11. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/bibtex_commonmeta/crossref.json +0 -0
  12. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/bibtex_commonmeta/journal_article.json +0 -0
  13. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/bibtex_out/journal_article.bib +0 -0
  14. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/cff/CITATION.cff +0 -0
  15. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/cff_commonmeta/CITATION.json +0 -0
  16. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/codemeta/codemeta.json +0 -0
  17. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/blog_post_1.json +0 -0
  18. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/citeproc.json +0 -0
  19. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/citeproc_no_author.json +0 -0
  20. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/codemeta.json +0 -0
  21. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/crossref_journal_article.json +0 -0
  22. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/csl_journal_article.json +0 -0
  23. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/datacite_blog_post.json +0 -0
  24. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/datacite_blog_post_citeproc.json +0 -0
  25. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/datacite_dataset.json +0 -0
  26. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/datacite_date.json +0 -0
  27. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/datacite_dryad.json +0 -0
  28. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/datacite_multiple_identifiers.json +0 -0
  29. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/datacite_subject_scheme_for.json +0 -0
  30. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/dataset_geo.json +0 -0
  31. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/inveniordm_software.json +0 -0
  32. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/journal_article.json +0 -0
  33. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/journal_article_datacite_reader.json +0 -0
  34. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/jsonfeed_blog_post_sathe.json +0 -0
  35. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/jsonfeed_cczero_license.json +0 -0
  36. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/jsonfeed_ghost_with_affiliations.json +0 -0
  37. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/jsonfeed_post_with_funding.json +0 -0
  38. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/jsonfeed_post_with_relationships.json +0 -0
  39. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/jsonfeed_wordpress_with_references.json +0 -0
  40. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/schemaorg_blog_posting.json +0 -0
  41. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/schemaorg_geolocation.json +0 -0
  42. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/commonmeta/schemaorg_journal_article.json +0 -0
  43. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/crossref/crossref_journal_article.json +0 -0
  44. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/crossref_out/crossref_journal_article.json +0 -0
  45. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/crossref_xml/crossref_elife_01567.xml +0 -0
  46. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/crossref_xml/journal_article.xml +0 -0
  47. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/crossref_xml_commonmeta/crossref_elife_01567.json +0 -0
  48. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/csl/citeproc.json +0 -0
  49. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/csl/citeproc_no_author.json +0 -0
  50. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/csl/csl_journal_article.json +0 -0
  51. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite/journal_article.json +0 -0
  52. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_reader/datacite_blog_post.json +0 -0
  53. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_reader/datacite_blog_post_citeproc.json +0 -0
  54. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_reader/datacite_dataset.json +0 -0
  55. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_reader/datacite_date.json +0 -0
  56. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_reader/datacite_dryad.json +0 -0
  57. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_reader/datacite_multiple_identifiers.json +0 -0
  58. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_reader/datacite_subject_scheme_for.json +0 -0
  59. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_reader/journal_article_datacite_reader.json +0 -0
  60. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_xml/full_v4_4.xml +0 -0
  61. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_xml/geolocation.xml +0 -0
  62. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_xml/schema_4_0.xml +0 -0
  63. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_xml_commonmeta/full_v4_4.json +0 -0
  64. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_xml_commonmeta/geolocation.json +0 -0
  65. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/datacite_xml_commonmeta/schema_4_0.json +0 -0
  66. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/inveniordm/inveniordm_software.json +0 -0
  67. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/jsonfeed/jsonfeed_blog_post.json +0 -0
  68. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/jsonfeed/jsonfeed_blog_post_sathe.json +0 -0
  69. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/jsonfeed/jsonfeed_cczero_license.json +0 -0
  70. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/jsonfeed/jsonfeed_ghost_with_affiliations.json +0 -0
  71. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/jsonfeed/jsonfeed_post_with_funding.json +0 -0
  72. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/jsonfeed/jsonfeed_post_with_relationships.json +0 -0
  73. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/jsonfeed/jsonfeed_wordpress_with_references.json +0 -0
  74. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/ris/crossref.ris +0 -0
  75. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/ris/pure.ris +0 -0
  76. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/ris_commonmeta/crossref.json +0 -0
  77. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/schemaorg/schemaorg_blog_posting.json +0 -0
  78. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/schemaorg/schemaorg_geolocation.json +0 -0
  79. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/schemaorg/schemaorg_gtex.json +0 -0
  80. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/schemaorg/schemaorg_journal_article.json +0 -0
  81. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/fixtures/schemaorg_out/journal_article.json +0 -0
  82. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/schemas/commonmeta_v1.0rc1.json +0 -0
  83. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/schemas/commonmeta_v1.0rc2.json +0 -0
  84. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/src/commonmeta_schema/__init__.py +0 -0
  85. {commonmeta_schema-1.0rc2.dev4 → commonmeta_schema-1.0rc2.dev6}/src/commonmeta_schema/resources.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commonmeta-schema
3
- Version: 1.0rc2.dev4
3
+ Version: 1.0rc2.dev6
4
4
  Summary: Commonmeta JSON Schemas and conformance fixtures
5
5
  Project-URL: Homepage, https://github.com/front-matter/commonmeta-schema
6
6
  Project-URL: Repository, https://github.com/front-matter/commonmeta-schema
@@ -2,13 +2,12 @@
2
2
  "id": "https://doi.org/10.59350/gj8re-sca95",
3
3
  "type": "BlogPost",
4
4
  "container": {
5
- "identifier": "https://rogue-scholar.org/blogs/anil",
6
- "identifier_type": "URL",
7
5
  "type": "Blog",
8
6
  "title": "Anil Madhavapeddy's feed",
7
+ "identifier": "https://rogue-scholar.org/blogs/anil",
8
+ "identifier_type": "URL",
9
9
  "platform": "Other"
10
10
  },
11
- "content": "<p>Fresh from <a href=\"https://anil.recoil.org/notes/rewilding-the-web-report\">rewilding the web</a>, I updated the Recoil self-hosting\ninfrastructure that <a href=\"https://nick.recoil.org\">Nick Ludlam</a> and I have run since 1997. Most exciting, this is now\n'email the hard way' that includes getting our very own dedicated IPv4 allocation routed thanks\nto my buddy <a href=\"https://github.com/samoht\">Thomas Gazagnaire</a> helping out from France!</p>\n<p>This post will be quite technical, aimed at those interested in building their own email stack. We'll talk about <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#why-run-your-own-email\">why bother</a> running your own email; <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#email-receipt\">receiving email</a> (covering <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#ip-reputation-and-denylists\">IP reputation</a>, <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#getting-our-very-own-ipv4-address-block\">getting our own IPv4 allocation</a>, <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#stopping-the-zombie-spam-horde\">stopping bots</a>, and <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#delivering-email-with-lmtp-and-sieve\">Sieve delivery</a>); <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#sending-email-to-other-people\">sending email</a> reliably with <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#spf-describes-who-can-send-email-for-a-domain\">SPF</a>, <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#dkim-cryptographic-signing-of-outbound-mail\">DKIM</a>, <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#dmarc-ties-it-together-and-provides-reporting-stats\">DMARC</a> and <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#srs-to-keep-email-forwarding-working\">SRS</a>; <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#accessing-email-for-users\">user access</a> via Dovecot IMAP and Roundcube webmail; and finally <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#what-else-is-left-to-do\">what's left to do</a> and a <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#is-this-a-negative-result-for-self-hosting\">reflection</a> on future research ideas.</p>\n<h2 id=\"why-run-your-own-email\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#why-run-your-own-email\"></a>Why run your own email?</h2>\n<p>For someone just getting into systems and networking, it's a hugely educational\nexperience. Running my own servers has been how I've learnt how the Internet\nworks, and how I got into open-source back in the day, by <a href=\"https://anil.recoil.org/notes/openbsd-developer\">installing OpenBSD</a> and <a href=\"https://anil.recoil.org/notes/commit-access-to-php\">fixing bugs in PHP</a>!</p>\n<p>More broadly, self-hosting is important for sovereign access to your own data\nas the <a href=\"https://doi.org/10.1145/3503158\">web steadily consolidates</a> among a few\nplayers. A <a href=\"https://www.netmeister.org/blog/mx-diversity.html\">2023 analysis</a>\nshowed that two companies can mostly read all of your email traffic:</p>\n<blockquote>\n<p>So all in all, the answer to the question of who can read your email pretty\nmuch boils down to -- yep -- \"Google and Microsoft\". Even if your domain\ndoesn't use one of their mail servers, chances are that <a href=\"https://mako.cc/copyrighteous/google-has-most-of-my-email-because-it-has-all-of-yours\">whoever you are sending mail to does</a>.\n<cite>-- <a href=\"https://www.netmeister.org/blog/mx-diversity.html\">MX diversity</a>, <a href=\"https://mstdn.social/@jschauma\">Jan Schaumann</a>, 2023</cite></p>\n</blockquote>\n<p>Email is right at the centre of our digital lives; consider just how many online\naccounts you could reset if your email got <a href=\"https://dl.acm.org/doi/10.1145/3716489.3728437\">hijacked or phished</a>. In many ways, it's <em>the</em> online service that connects up all the other ones.</p>\n<h3 id=\"should-you-run-your-own-email\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#should-you-run-your-own-email\"></a>Should you run your own email?</h3>\n<p>Hosting your email is a fair bit of work, but that's spread over a long period of time as you keep an eye on it. <a href=\"https://nick.recoil.org\">Nick Ludlam</a> and I started our hosting adventure back in 1998 or so when we <a href=\"https://anil.recoil.org/notes/netapp-tr-3071-1\">worked at NASA</a> for the summer. The first time we did a major server move back in 2002, here's Nick and <a href=\"https://nanog.org/events/nanog-48/content/2951/\">Chris Luke</a>\nloading up our second server into a dodgy white minivan to host in Easynet, right outside <a href=\"https://en.wikipedia.org/wiki/Cyberia,_London\">London's first Internet cafe</a>.</p>\n<p><img alt=\"%c\" src=\"https://anil.recoil.org/images/moving-recoil-1.webp\" title=\"From Cyberia outside Fitzrovia over to Brick Lane and Easynet hosting! (2002)\"/></p>\n<p>If you do decide to have a go for yourself, you'll need to find stable Internet hosting (your own home network isn't the best choice,\nfor reasons we'll see later in this post), and also build up a reputation. Luckily, finding stable Internet is <em>much</em> easier these days than it was in the late 90s, and we use <a href=\"https://mythic-beasts.com\">Mythic Beasts</a> who are excellent, reliable and local.</p>\n<p>I'll explain in this post how we obtained our own dedicated IPv4 address block\nto help build up a high deliverability index for our personal email that's\nentirely independent! While email looks like one service to most users, it\nis actually three separate activities: <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#email-receipt\">email receipt</a>, <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#sending-email-to-other-people\">email\nsubmission</a>, and <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#accessing-email-for-users\">email access</a>. I'll dive into how each of\nthese work on Recoil now, in case it's useful for your own setup.</p>\n<h2 id=\"email-receipt\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#email-receipt\"></a>Email receipt</h2>\n<p>Each domain (like <code>recoil.org</code>) on the Internet runs an SMTP server that allows\nit to receive email from other servers. You can query this server for any\ndomain by looking up its 'MX' DNS record:</p>\n<pre><code class=\"language-bash\">$ host -t mx recoil.org\nrecoil.org mail is handled by 10 pork.recoil.org.\n</code></pre>\n<p>This indicates that the Internet host <code>pork.recoil.org</code> must accept connections from any server\non the internet that wishes to deliver email to <code>&lt;email&gt;@recoil.org</code>.\nThe core difficulty is that <a href=\"https://www.rfc-editor.org/rfc/rfc5321\">SMTP</a> (as\ndesigned in the more trusting 1980s) doesn't mandate a built-in proof of trust.\nAnyone can claim to be anyone else, and the 'sender' in the email we receive\ncan be trivially forged.</p>\n<p>The IETF's <a href=\"https://datatracker.ietf.org/doc/html/rfc2505\">response</a> was to accrete a stack of checks over the years that an\nemail sender must pass, or be filtered by the recipient. If we mess these\nidentity checks up, then our email won't get delivered reliably across the\nInternet and the service won't be very useful.</p>\n<h3 id=\"ip-reputation-and-denylists\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#ip-reputation-and-denylists\"></a>IP reputation and denylists</h3>\n<p>Spam was cheap to send from anywhere on the Internet, and so naturally grew as\nthe wider network gained adoption. Paul Vixie came up with a <a href=\"https://en.wikipedia.org/wiki/Domain_Name_System_blocklist\">DNS-based\nblocklist</a> back in\n1997. Since then, many more have sprung up, operated by organisations like\n<a href=\"https://www.spamhaus.org/\">Spamhaus</a>, <a href=\"https://www.spamcop.net/\">Spamcop</a> and\n<a href=\"https://www.barracudacentral.org/rbl\">Barracuda</a> that aggregate reports about\nbotnets, compromised hosts and spammers into lists that any email server can\nconsult.</p>\n<p>The DNS blacklist/whitelist protocol (<a href=\"https://www.rfc-editor.org/rfc/rfc5782\">RFC 5782</a>) is super simple and can be queried right from your command line:</p>\n<pre><code class=\"language-bash\">$ dig 2.0.0.127.zen.spamhaus.org +short # testing address\n127.0.0.2\n127.0.0.10\n127.0.0.4\n</code></pre>\n<p>That's a localhost testing address, but the presence of a DNS record in the RBL\nmeans that that server is suspect. This is where having control of your own\nIPv4 address really pays off. Reputation for email via these RBLs accrues\nagainst the address and not the domain. In theory, the IP address you use for\nyour own self-hosted email <a href=\"https://doi.org/10.1145/3419394.3423657\">might have been re-used</a> from a cloud provider\nby someone else, and therefore be tainted by other people's bad behaviour.</p>\n<p><em>(Update: <a href=\"https://ryan.freumh.org\">Ryan Gibb</a> observes that as these lists often work on IP blocks, your IP neighbour can also effect your reputation in a multitenant cloud. He reports that he uses <a href=\"https://www.hetzner.com/\">Hetzner</a> for his use who <a href=\"http://bef.no/DitchingWindowsAndAWS/\">manually allowlist</a> SMTP servers to minimise abuse).</em></p>\n<h3 id=\"getting-our-very-own-ipv4-address-block\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#getting-our-very-own-ipv4-address-block\"></a>Getting our very own IPv4 address block</h3>\n<p>In contrast, a fresh IPv4 starts neutral and earns its reputation through\nconsistent, well-formed email sending. The only way to fully control this is\nto control the address space itself, which is why Team Recoil now have our very\nown IPv4 address block: <code>185.33.27.0/24</code>!</p>\n<p>Getting our very own address allocation involved joining a very long queue due to IPv4\nexhaustion. The <a href=\"https://www.ripe.net/\">RIPE NCC</a> is the regional registrar for\nEurope and ran out of unallocated IPv4 space in <a href=\"https://www.ripe.net/publications/news/the-ripe-ncc-has-run-out-of-ipv4-addresses/\">November\n2019</a>,\nand since then the only way to get an allocation directly from RIPE is via a\n<a href=\"https://www.ripe.net/manage-ips-and-asns/ipv4/\">waiting list</a> for small allocations.</p>\n<blockquote>\n<p>While we have run out of IPv4 addresses, RIPE NCC members can still request a\nsingle /24 allocation (256 addresses). [...] Requests are added to a waiting\nlist and processed when we recover IPv4 addresses in the future. [...] This\nis only available to LIRs that have never received an IPv4 allocation from\nthe RIPE NCC before (of any size).\n<cite>-- <a href=\"https://www.ripe.net/manage-ips-and-asns/ipv4/\">RIPE NCC, /24 Allocation via the Waiting List</a></cite></p>\n</blockquote>\n<p>I got into that queue from the UK, and my buddy <a href=\"https://github.com/samoht\">Thomas Gazagnaire</a> did the same from France.\nHe got to the head of his queue well ahead of me, and we got allocated our\nown <code>/24</code> block after about a six month wait.</p>\n<h4 id=\"signing-up-to-ripe-for-yourself\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#signing-up-to-ripe-for-yourself\"></a>Signing up to RIPE for yourself</h4>\n<p>If you want to do this yourself and are based in Europe, then pay the annual RIPE NCC membership fee to open a\n<a href=\"https://www.ripe.net/membership/become-a-member/\">Local Internet Registry</a>\n(LIR) account.</p>\n<p>After that, confirm you've never previously received an IPv4 allocation,\njoin the waiting list, and wait until enough addresses are recovered (e.g. from\ndefunct LIRs, returned space, or revoked allocations) for a slot to come\nup. The wait is currently a year or two I think and seems to depend which country you're in.</p>\n<p><a href=\"https://www.ripe.net/manage-ips-and-asns/ipv4/ipv4-waiting-list/\"> <img alt=\"%c\" src=\"https://anil.recoil.org/images/ripe-ss-2.webp\" title=\"RIPE LIR waiting list statistics\"/> </a></p>\n<h4 id=\"setting-up-ipv4-routes-for-an-autonomous-system\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#setting-up-ipv4-routes-for-an-autonomous-system\"></a>Setting up IPv4 routes for an autonomous system</h4>\n<p>The next step was to route this allocation to an actual machine hooked up\nto the public Internet. While it is possible to <a href=\"https://en.wikipedia.org/wiki/Border_Gateway_Protocol\">advertise our own routes</a>, in the\ninterests of expediency we decided to request <a href=\"http://mythic-beasts.com\">Mythic Beasts</a> to take care of it for us and handle the peering.</p>\n<p>The procedure to do this via RIPE is straightforward. The IPv4 block is given\nan assignment by creating a '<a href=\"https://en.wikipedia.org/wiki/Resource_Public_Key_Infrastructure\">RPKI ROA</a>' in\ntheir database. This is a PKI chain-of-trust used to connect up IP routing blocks on the\nInternet.</p>\n<p><img alt=\"%c\" src=\"https://anil.recoil.org/images/ripe-ss-1.webp\"/></p>\n<p>An <a href=\"https://en.wikipedia.org/wiki/Autonomous_system_(Internet)\">Autonomous System</a>\n(AS) is a unit of independent routing policy on the Internet and announced to the rest of the world via\n<a href=\"https://www.rfc-editor.org/rfc/rfc4271\">BGP</a>.\nWorking out who actually owns a given ASN turns out to be surprisingly hard as the WHOIS\ndatabases are inconsistently updated, but <a href=\"https://doi.org/10.1145/3487552.3487853\">third-party databases exist</a>\nto help.\nIn our case <a href=\"https://ipinfo.io/AS44684/185.33.27.0/24\">our IP block</a> is connected to the <a href=\"https://ipinfo.io/AS44684\">Mythic Beasts AS44684</a>.</p>\n<p>Once that was sorted, RIPE once again uses DNS to announce the connection to Mythic:</p>\n<pre><code class=\"language-bash\">$ dig soa -x 185.33.27.0 @pri.authdns.ripe.net +noall +authority\n27.33.185.in-addr.arpa.\t86400\tIN\tNS\tns1.mythic-beasts.com.\n27.33.185.in-addr.arpa.\t86400\tIN\tNS\tns2.mythic-beasts.com.\n</code></pre>\n<p>Another nice aspect is being able to control our own reverse DNS to this IP block, which is another\nimportant signal for email reputation:</p>\n<pre><code class=\"language-bash\">$ host pork.recoil.org\npork.recoil.org has address 185.33.27.128\npork.recoil.org has IPv6 address 2a00:1098:39c::3\n$ host 185.33.27.128\n128.27.33.185.in-addr.arpa domain name pointer pork.recoil.org.\n</code></pre>\n<h3 id=\"stopping-the-zombie-spam-horde\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#stopping-the-zombie-spam-horde\"></a>Stopping the Zombie spam horde</h3>\n<p>We've so far gone to an enormous amount of hassle to get a clean IPv4 block,\nbut this is necessary but not sufficient to protect ourselves! We also need to\nconfigure our server to defend itself against the zombie botnet hordes.</p>\n<p>The overwhelming majority of incoming TCP connections on port 25\nare botnets attempting to deliver spam, probe for open relays, guess\ncredentials (or more recently) harvest data for AI training.\nSpending CPU cycles parsing the body of every one of these requests is both\nwasteful and dangerous, so a good setup will try to filter these out as early as\npossible.</p>\n<p>We first deploy Postfix's <a href=\"https://www.postfix.org/POSTSCREEN_README.html\">postscreen</a>, which\nlistens on port 25 as the first port of call. Architecturally it's a protocol proxy that accepts the\nTCP connection, runs a battery of cheap checks:</p>\n<ul>\n<li>DNSBL lookups in parallel from multiple providers</li>\n<li>adds a deliberate pre-greet pause from <a href=\"https://www.rfc-editor.org/rfc/rfc5321\">RFC 5321 §3.1</a> that catches\nbots which start talking before the server's banner appears</li>\n<li>a couple of pipelining and non-SMTP-command tests to check for compliance</li>\n</ul>\n<p>It only hands the connection off to the real <code>smtpd</code> process if the client looks legitimate after\nthese. Bad clients are dropped during the pre-greet pause with a temporary failure, which\nwill encourage false positives to retry in the future.\nInterestingly this proxy is exactly what <a href=\"https://anil.recoil.org/notes/cacm-docker-cover\">we do in Docker for Desktop</a>, where a userspace <a href=\"https://anil.recoil.org/notes/icfp25-ocaml5-js-docker\">OCaml VPNKit proxy</a> mediates between containers and the host network without exposing the host stack directly. I'm going to reimplement postscreen in OxCaml soon...</p>\n<p>For those configuring your own server, the relevant Postfix knobs are in <code>main.cf</code>:</p>\n<pre><code class=\"language-ini\">postscreen_dnsbl_sites = zen.spamhaus.org*3 bl.spamcop.net*2 b.barracudacentral.org*2\npostscreen_dnsbl_action = enforce # reject when DNSBL score crosses threshold\npostscreen_greet_action = enforce # reject pre-greet slammers\n</code></pre>\n<p>The <code>*3</code> and <code>*2</code> weights let us combine blocklists rather than trust any single\nsource; in the above we trust Spamhaus alone and the two weaker lists need to both agree to\nreject.\nJust postscreen by itself seems to reject over 90% of incoming spam requests, but there's\nanother trick we can apply.</p>\n<p>One minor wrinkle that <a href=\"https://nick.recoil.org\">Nick Ludlam</a> noticed is that Apple's iCloud email service interacts\nbadly with postscreen. Apple's outbound MX pool that delivers email doesn't\nretry from the same IP, which means postscreen's allowlist for that connection\nnever matches and mail can stay in limbo for hours. This isn't really a\nmisconfiguration on our end, but it affect users badly.</p>\n<p>The fix (since Apple still owns the whole of <code>17.0.0.0/8</code>!) is to allowlist that whole range up front in\n<code>postscreen_access.cidr</code> so it bypasses the protocol tests entirely. The\n<a href=\"https://docs.mailcow.email/manual-guides/Postfix/u_e-postfix-postscreen_whitelist/\">mailcow allowlist guide</a>\nwalked me through the syntax in case you hit the same problem:</p>\n<pre><code class=\"language-bash\"># /etc/postfix/postscreen_access.cidr\n17.0.0.0/8 permit # Apple iCloud MX pool is somewhere in here\n</code></pre>\n<h4 id=\"greylisting\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#greylisting\"></a>Greylisting</h4>\n<p>Once a connection survives postscreen, our next defensive layer is\ngreylisting (<a href=\"https://www.rfc-editor.org/rfc/rfc6647\">RFC 6647</a>),\nimplemented for us by <a href=\"https://rspamd.com/\">rspamd</a>. The idea is that\nthe first time we ever see a particular source, our server returns a temporary failure rather\nthan accepting the message, and records the source for future reference.</p>\n<p>Legitimate MTAs are required by <a href=\"https://www.rfc-editor.org/rfc/rfc5321\">RFC 5321</a> to queue/retry after a\nfew minutes. When the retry comes in, we'll have seen it from the first attempt\nand then let the message through. A large fraction of botnets are\n\"single-shot\" delivery engines that just move on to the next victim when they\nget a failure, since maintaining a retry queue is expensive when you're\nshotgunning millions of messages. The cost to a <em>real</em> sender is a one-time\ndelay of a few minutes, which is amortised across all users from that source\n(the greylisting only happens to the first sender).</p>\n<p>All these mechanics are implemented in rspamd, which itself has a nice local connection: its author\n<a href=\"https://github.com/vstakhov\">Vsevolod Stakhov</a> did his PhD in the CL with <a href=\"mailto:jon.crowcroft@cl.cam.ac.uk\">Jon Crowcroft</a> and me, and I spent\nmuch time back in 2015 working with him with <a href=\"https://www.highsecure.ru/httpcrypt.pdf\">HTTPCrypt</a>, a scheme for\nopportunistic HTTP encryption that uses NaCl-style cryptography to skip the full TLS\nhandshake by passing the server public key out-of-band (typically via DNS).\nThe paper got soundly <a href=\"https://www.usenix.org/conference/usenixsecurity15\">rejected from USENIX Security 2015</a>\nfor reasons I can't remember but weren't very important, but the\nprotocol lives on as the <a href=\"https://docs.rspamd.com/developers/encryption/\">encryption layer between rspamd and its clients</a>!</p>\n<p>Ok, so now by the time a message has survived postscreen and greylisting, well over 99%\nof the original bot traffic has been turned away at the door for a tiny\nfraction of the CPU cost of actually reading it. After this, we still need\nto do a bit of work scanning the content itself.</p>\n<h4 id=\"milter-clamav-and-bayesian-filtering\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#milter-clamav-and-bayesian-filtering\"></a>Milter, ClamAV and Bayesian filtering</h4>\n<p>Everything that makes it past postscreen and greylisting gets handed to rspamd\nover the <a href=\"https://www.postfix.org/MILTER_README.html\">milter</a> protocol.\nOur postfix is configured to consult rspamd for every message,\nwhich allows rspamd to either hard reject or defer a delivery while the\nsending server is still 'on the line'</p>\n<p>This allows dodgy mail to be refused at the source rather than accepted for delivery\nand then bounced afterwards (which is unreliable, as the bouncing address may also\nbe fake and cause <a href=\"https://en.wikipedia.org/wiki/Backscatter_(email)\">backscatter spam</a> to be generated by us!).</p>\n<p>The Postfix configuration for this milter is as easy as running the rspamd\ndaemon configured to listen on localhost.</p>\n<pre><code class=\"language-ini\"># /etc/postfix/main.cf\nsmtpd_milters = inet:localhost:11332\n</code></pre>\n<p><a href=\"https://www.clamav.net/\"> <img alt=\"%rc\" src=\"https://anil.recoil.org/images/clamav-logo.webp\"/> </a>\nThe first port of call is to filter out messages with known virus attachments.\n<a href=\"https://www.clamav.net/\">ClamAV</a> is what we use for this, and it maintains\nits own <a href=\"https://docs.clamav.net/manual/Signatures.html\">virus signature</a> database.\nrspamd hands the message body to the local <code>clamd</code> daemon over a Unix\nsocket, and rejects outright on a virus hit so the message is never delivered.</p>\n<pre><code class=\"language-ini\"># /etc/rspamd/local.d/antivirus.conf\nclamav { type = \"clamav\"; servers = \"/run/clamav/clamd.ctl\"; action = \"reject\"; }\n</code></pre>\n<p>The other main filter (among many) is rspamd's \"<a href=\"https://docs.rspamd.com/configuration/statistic/\">Bayesian\nclassifier</a>\" that scores each\nincoming message against a dynamic corpus of known <a href=\"https://en.wikipedia.org/wiki/Anti-spam_techniques\">spam and\nham</a> messages stored in\n<a href=\"https://redis.io/\">Redis</a>. The classifier auto-learns from messages that score\nextremely high or low, but it can also be personalised by the Recoil users by\nkeeping an eye on each user's Junk folder and adding those to the classifier.</p>\n<p>These messages are learnt by being piped to the <code>rspamc</code> command, which can\nlearn both ham and spam on stdin. Over a few weeks of doing this on every\nfalse-positive (or false-negative), the classifier gets pretty good\nat matching what our users want to see without having to maintain a static database.</p>\n<pre><code class=\"language-bash\">$ rspamc stat\nResults for command: stat (0.028 seconds)\nMessages scanned: 6206\nMessages with action reject: 135, 2.18%\nMessages with action soft reject: 0, 0.00%\nMessages with action rewrite subject: 0, 0.00%\nMessages with action add header: 137, 2.21%\nMessages with action greylist: 658, 10.60%\nMessages with action no action: 5276, 85.01%\nMessages treated as spam: 272, 4.38%\nMessages treated as ham: 5934, 95.62%\n</code></pre>\n<h3 id=\"delivering-email-with-lmtp-and-sieve\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#delivering-email-with-lmtp-and-sieve\"></a>Delivering email with LMTP and Sieve</h3>\n<p>Once our intrepid email message has run the gauntlet of postscreen,\ngreylisting, rspamd, ClamAV and Bayesian classifiers, we <em>finally</em> get to\nactually send it to the right user.</p>\n<p>By default Postfix would just write the file straight into the user's home\ndirectory, but that's not much use in the modern world where the volume of\nemail most people receive means that we'd like to file them into folders.</p>\n<p>We therefore hand the message over to <a href=\"https://www.dovecot.org/\">Dovecot</a> via\n<a href=\"https://www.rfc-editor.org/rfc/rfc2033\">LMTP</a>, which is basically SMTP (the protocol\nwe used to receive the email from the outside world), but without the queueing complexity.\nThis handoff happens over a normal Unix domain socket that's inside Postfix's\nqueue directory:</p>\n<pre><code class=\"language-ini\"># /etc/postfix/main.cf\nvirtual_alias_domains = recoil.org\nvirtual_alias_maps = hash:/etc/postfix/recoil.org\nmailbox_transport = lmtp:unix:private/dovecot-lmtp\n</code></pre>\n<p><code>virtual_alias_maps</code> turns an arbitrary <code>anything@recoil.org</code> address into an\naddress that can be delivered locally (e.g. <code>anil@recoil.org</code> goes to <code>avsm@pork.recoil.org</code>).\nThe reason for handing off to Dovecot rather than letting Postfix write the maildir directly\nis that Dovecot then owns the local user operations of indexing, quotas, full-text search and Sieve\nfiltering. We'll come back to this in the <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#accessing-email-for-users\">email retrieval</a> section.</p>\n<h4 id=\"durable-on-disk-storage-with-maildir\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#durable-on-disk-storage-with-maildir\"></a>Durable on-disk storage with Maildir</h4>\n<p>Our storage format on disk is the reliable old <a href=\"https://en.wikipedia.org/wiki/Maildir\">Maildir</a>\nformat, which stores each email as a single file under each user's <code>~/Maildir</code>. It's a format\nthat we've been using on Recoil since 1998, when we first used <a href=\"http://qmail.org/man/man5/maildir.html\">qmail</a> as our mail server.\nThe reason I like it so much is that <a href=\"https://github.com/avsm/maildir-eio\">processing libraries</a> are\ntrivial to write, so email is never locked up in a proprietary format or database over the march\nof decades.</p>\n<p>The format itself is minimal. Each <code>~/Maildir</code> is just three\nsubdirectories <code>tmp/</code>, <code>new/</code> and <code>cur/</code>.</p>\n<pre><code class=\"language-bash\">/home/avsm/Maildir/.archive.2018\n/home/avsm/Maildir/.archive.2018/tmp\n/home/avsm/Maildir/.archive.2018/cur\n/home/avsm/Maildir/.archive.2018/new\n/home/avsm/Maildir/.github.mention\n/home/avsm/Maildir/.github.mention/cur\n/home/avsm/Maildir/.github.mention/new\n/home/avsm/Maildir/.github.mention/tmp\n...\n</code></pre>\n<p>The concurrency story is much simpler as <a href=\"https://en.wikipedia.org/wiki/Rename_(computing)\">POSIX guarantees local atomicity</a>\nof file rename operations. An incoming message\nis first written to a unique file in <code>tmp/</code>, then once fully written is (atomically) renamed\ninto <code>new/</code>.</p>\n<p>A client reading the Maildir then moves files from <code>new/</code> into <code>cur/</code> once\nthey've been seen. The message file itself is immutable, and so clients use the\nfilename to store message information by appending a flag suffix (e.g. <code>:2,S</code>\nfor Seen, <code>:2,SR</code> for Seen and Replied). With this setup, no user level mailbox-wide locking is needed and so\nPostfix can deliver a new message without synchronisation while normal email\nreading is going on.</p>\n<h4 id=\"indexing-the-email-with-flatcurve\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#indexing-the-email-with-flatcurve\"></a>Indexing the email with Flatcurve</h4>\n<p>I did evaluate some much more modern email servers like <a href=\"https://stalw.art/\">Stalwart</a>\nwhich have tons of cool features, but ultimately decided against switching\nbecause they don't support Maildir. They instead require stashing email in a\ncustom database format (e.g. in RocksDB) which (I think) mixes up the durability\nof email with having fast search.\nInstead, I took advantage of Dovecot support for full text indexing via a\n<em>separate</em> index, which is the\n<a href=\"https://github.com/slusarz/dovecot-fts-flatcurve\">Flatcurve</a> full-text index.</p>\n<p>Flatcurve is actually a wrapper around the venerable <a href=\"https://xapian.org/\">Xapian</a>,\nwhich (like rspamd) is yet another locally developed Cambridge technology!\n<a href=\"https://en.wikipedia.org/wiki/Martin_Porter\">Martin Porter</a> (inventor of the famous\n<a href=\"https://en.wikipedia.org/wiki/Stemming\">stemming algorithm</a>), did the Computer Science\nDiploma in the CL in 1967 and released the first version of Xapian in 2002. Note that\nthere's no connection to our <a href=\"https://anil.recoil.org/papers/2010-icfp-xen\">OCaml Xen XAPI toolstack</a> which was\ndeveloped by us independently in 2004!</p>\n<p>In our setup, Flatcurve keeps a separate Xapian index per mailbox under\n<code>~/Maildir/fts-flatcurve</code> and updates it automatically as new mail arrives.\nThe Dovecot side of the config is just:</p>\n<pre><code class=\"language-ini\"># /etc/dovecot/conf.d/90-fts-flatcurve.conf\nmail_plugins { fts = yes; fts_flatcurve = yes }\nfts_autoindex = yes\n</code></pre>\n<p>Flatcurve also ships CLI tools as part of the Dovecot plugin to mess around\nwith the index or do CLI searches (handy for agentic search!):</p>\n<pre><code class=\"language-bash\"> $ doveadm fts flatcurve stats -u avsm INBOX\nINBOX guid=436177290d20ee4e664a00007b9d9320 last_uid=1018743\nmessages=83485 shards=2 version=1\n</code></pre>\n<h4 id=\"the-sieve-language-for-server-side-filtering\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#the-sieve-language-for-server-side-filtering\"></a>The Sieve language for server-side filtering</h4>\n<p>Before the message lands in the maildir, we also need to decide which folder\nto put it into, or do other preprocessing like labeling. Since this can get\narbitrarily complicated based on the user's needs, we use a\n<a href=\"https://pigeonhole.dovecot.org/\">Pigeonhole Sieve</a> plugin that runs user-defined\ndelivery-time filters.</p>\n<p>Sieve (<a href=\"https://www.rfc-editor.org/rfc/rfc5228\">RFC 5228</a>) is a declarative language designed specifically for mail\nfiltering. There's a system-wide script that runs first and\nfiles anything rspamd has flagged into <code>Junk</code>, and then each user's personal\nscript runs:</p>\n<pre><code class=\"language-ini\"># /etc/dovecot/conf.d/90-sieve.conf\nsieve_script spam-to-junk { type = before; path = /etc/dovecot/sieve/spam-to-junk.sieve }\nsieve_script personal { path = ~/sieve; active_path = ~/.dovecot.sieve }\n</code></pre>\n<pre><code class=\"language-sieve\"># /etc/dovecot/sieve/spam-to-junk.sieve\nrequire [\"fileinto\", \"mailbox\"];\nif header :contains \"X-Spam\" \"Yes\" {\n fileinto :create \"Junk\";\n stop;\n}\n</code></pre>\n<p>Running these rules at delivery time on the server\nmeans the same rules apply whether I'm eventually reading mail on my laptop, phone, or\nvia webmail. I can also write Sieve rules for very custom vacation rules, email\npriorities, and coding agents like Claude can easily figure out the DSL intricacies.\nI found the <a href=\"https://gist.github.com/Hotrod369/6b7a24e1ea060e48e0c02459cbb950a0\">Sieve cheatsheet</a> very\nuseful here: you can do things like <a href=\"https://gist.github.com/Hotrod369/6b7a24e1ea060e48e0c02459cbb950a0#complex-sieve-script-examples\">modify messages</a>, create dynamic folders and <a href=\"https://doc.dovecot.org/2.3/configuration_manual/sieve/extensions/editheader/\">edit headers</a>.</p>\n<p>There's also a \"ManageSieve\" (<a href=\"https://www.rfc-editor.org/rfc/rfc5804\">RFC 5804</a>) daemon running,\nwhich lets a mail client edit a user's Sieve script remotely without needing shell access.\nI got this working with both <a href=\"https://addons.thunderbird.net/en-US/thunderbird/addon/sieve/\">Thunderbird</a>\nand <a href=\"https://plugins.roundcube.net/packages/kolab/managesieve\">Roundcube</a> which bundles a plugin natively.</p>\n<p>My email filter's massive, but I generate it from OCaml code that outputs something like:</p>\n<pre><code class=\"language-sieve\">if header :contains \"List-Id\" \"caml-list.inria.fr\"\n{\n fileinto \"dev.caml-list\";\n stop;\n}\nif header :contains \"List-Id\" \"types-list.LISTS.SEAS.UPENN.EDU\"\n{\n fileinto \"lists.types\";\n stop;\n}\n&lt;...&gt;\n</code></pre>\n<p>The Junk Bayesian training loop from earlier piggybacks on this too, as a Sieve\nIMAP event\n(<a href=\"https://www.rfc-editor.org/rfc/rfc6785\">RFC 6785</a>) script fires on each\nfolder move, which then pipes the message to <code>rspamc learn_spam</code> or <code>learn_ham</code>.\nThis might all feel like a Rube Goldberg machine, but each component does have\nits own specialised role.</p>\n<h2 id=\"sending-email-to-other-people\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#sending-email-to-other-people\"></a>Sending email to other people</h2>\n<p>We've so far put a stupid amount of effort into <em>receiving</em> email safely, but\nthis wouldn't be much use if we also can't reliably <em>send</em> email that won't\nget rejected.\nFailing any one of the gauntlet of checks by the hyperscalers will send our mail to\nsomeone's spam folder or be quietly dropped. There are three separate protocols\nthat work together to avoid this unfortunate outcome: SPF, DKIM and DMARC.</p>\n<h3 id=\"spf-describes-who-can-send-email-for-a-domain\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#spf-describes-who-can-send-email-for-a-domain\"></a>SPF describes who can send email for a domain</h3>\n<p>The <a href=\"https://www.rfc-editor.org/rfc/rfc7208\">SPF</a> (Sender Policy Framework) protocol\nis a DNS TXT record at the apex of our email domain which declares which IP addresses are\nallowed to <em>originate</em> mail claiming to be from <code>@recoil.org</code>. As before, we can\nquery the live record for Recoil from the CLI:</p>\n<pre><code class=\"language-bash\">$ dig +short TXT recoil.org\n\"v=spf1 a mx -all\"\n\"Llamaz United\"\n$ dig mx recoil.org +short\n10 pork.recoil.org.\n</code></pre>\n<p>The first one is the SPF record, and the second is a random record I created in\n1999 to test TXT records. The SPF entry says that the only valid senders for\n<code>recoil.org</code> are whatever hosts the <code>MX</code> records of <code>recoil.org</code> point to. In\nour case this is just <code>pork.recoil.org</code>, and everything else is expected\nto be illegitimate email not authorized by us.</p>\n<p>It's possible to also declare a softer <code>~all</code> softfail that lets receivers accept dubious mail with a\nwarning. This setup is safe for Recoil because we never send legitimate mail from anywhere except our\nown mail server.</p>\n<p>One little footgun is that we have lots of IP addresses bound to the\npork.recoil.org host (because of the aforementioned /24 IPv4 block that we were\nallocated), and so the Postfix daemon needs to be bound specifically to one\naddress to ensure that all of the outbound TCP connections it makes are indeed\nfrom the MX entry and not another address from our pool.</p>\n<pre><code class=\"language-ini\"># /etc/postfix/main.cf\nsmtp_bind_address = 185.33.27.128\nsmtp_bind_address6 = 2a00:1098:39c::3\n</code></pre>\n<p>Without these, Postfix would happily send out from whatever address the kernel\ndecides to use, which might be the IPv4 we use for the webmail or some other\nservice entirely, and the receiver's SPF check would fail...</p>\n<h3 id=\"dkim-cryptographic-signing-of-outbound-mail\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#dkim-cryptographic-signing-of-outbound-mail\"></a>DKIM: cryptographic signing of outbound mail</h3>\n<p>SPF only authenticates the connecting IP of the email server to other people, but\nsays nothing about the contents of the message itself. We could still use a way\nto authenticate that a full message has been certified by Recoil as originating\nfrom us, even if that message has been through several other email relays (e.g. by\nbeing forwarded).</p>\n<p>The <a href=\"https://www.rfc-editor.org/rfc/rfc6376\">DKIM</a> (DomainKeys Identified Mail)\nprotocol adds a per-message cryptographic signature, in a <code>DKIM-Signature:</code>\nemail header. This covers a canonicalised form of the body and selected headers,\nto permit some flexibility in rearranging the email but still be robust against\ntampering. The public verification keys for DKIM live in the public DNS and so\nare available for any receiver to easily check.</p>\n<p>The job of signing every single message leaving Recoil is <a href=\"https://docs.rspamd.com/modules/dkim_signing/\">rspamd's job</a>. Our\nDKIM private key lives on disk and never leaves the server. rspamd\nadds the signature to every outbound message during the same milter pass that\n<a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#milter-clamav-and-bayesian-filtering\">scores inbound mail</a>:</p>\n<pre><code># /etc/rspamd/local.d/dkim_signing.conf\ndomain {\n recoil.org { selector = \"mail\"; path = \"/var/lib/rspamd/dkim/recoil.org.mail.key\" }\n}\n</code></pre>\n<p>Since you don't want to have the same private key in use forever, DKIM\nsupports rotation via \"selectors\" (<code>mail</code> in our case). This lets us rotate keys by\npublishing a new public key under a new selector while keeping the old one\nlive, so signatures on already-sent mail still verify. The public side\nlives in DNS at <code>&lt;selector&gt;._domainkey.&lt;domain&gt;</code>:</p>\n<pre><code>$ dig +short TXT mail._domainkey.recoil.org\n\"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\"\n</code></pre>\n<p>One gotcha if you're setting this up yourself is that DKIM TXT records\nfrequently exceed the <a href=\"https://www.rfc-editor.org/rfc/rfc7208\">255-byte string\nlimit</a> and have to be split into\nmultiple quoted strings inside one record. Most authoritative DNS providers\nwill do that for you, but you may need to mess around in their various web UIs to figure out how.</p>\n<p>Another oddity about DKIM is that it's really intended for live verification. If you want\nto go back and re-verify some emails that are (say) a few years old, then the DNS keys\nwill have expired and so you won't be able to do so. If anyone knows of any DKIM\n<a href=\"https://certificate.transparency.dev/\">transparency logs</a> like exist for TLS, I'd love\nto try it to go back over my historical email and do some data mining.</p>\n<h3 id=\"dmarc-ties-it-together-and-provides-reporting-stats\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#dmarc-ties-it-together-and-provides-reporting-stats\"></a>DMARC ties it together and provides reporting stats</h3>\n<p>Neither SPF nor DKIM actually check the <code>From:</code> header that\ndecides what our users actually see in their mail client:</p>\n<ul>\n<li>SPF only authenticates the envelope sender (the <code>MAIL FROM</code> SMTP command)</li>\n<li>DKIM only authenticates the sending domain in its own <code>d=</code> tag.</li>\n</ul>\n<p>Without some glue, a spammer could pass the SPF check for their own <code>evil.example.com</code>,\nthen sign a message with a valid DKIM key for <code>evil.example.com</code>, and\n<em>still</em> write <code>From: anil@recoil.org</code> in the message that the user eventually reads.\nThe protocol glue to prevent this is <a href=\"https://www.rfc-editor.org/rfc/rfc7489\">DMARC</a>, which checks\nthat the domains authenticated by SPF and DKIM actually match the visible\n<code>From:</code> in the email message, and also tells receivers what to do when the check fails.</p>\n<p>As you might have guessed by now, this involved yet another DNS record:</p>\n<pre><code>$ dig +short TXT _dmarc.recoil.org\n\"v=DMARC1; p=quarantine; rua=mailto:postmaster@pork.recoil.org\"\n</code></pre>\n<p>The strongest policy is <code>p=reject</code>, but we're going for a softer 'quarantine'\nuntil I'm comfortable with the setup for a few more months.\nA <em>really</em> useful part for actually debugging deliverability (given how many\nthird parties are involved here) is <code>rua=</code>, which is the email address for\na regular aggregate report.</p>\n<p>Once a day or so, every major receiver who gets email from Recoil (including Google,\nMicrosoft, Yahoo, Fastmail, and some smaller ones) sends an XML report to\nthis address summarising the messages they saw claiming to be from\n<code>recoil.org</code>.\nSome of these actually look like valid fails; for example this one from Yahoo\nseems to indicate that we've had some email sent <em>not</em> from our servers:</p>\n<pre><code class=\"language-xml\">$ gzcat yahoo.co.uk!recoil.org!1780617600!1780703999.xml.gz\n&lt;?xml version=\"1.0\"?&gt;\n&lt;feedback&gt;\n &lt;report_metadata&gt;\n &lt;org_name&gt;Yahoo&lt;/org_name&gt;\n &lt;email&gt;dmarchelp@yahooinc.com&lt;/email&gt;\n &lt;report_id&gt;1780732274.742817&lt;/report_id&gt;\n &lt;date_range&gt;\n &lt;begin&gt;1780617600&lt;/begin&gt;\n &lt;end&gt;1780703999&lt;/end&gt;\n &lt;/date_range&gt;\n &lt;/report_metadata&gt;\n &lt;policy_published&gt;\n &lt;domain&gt;recoil.org&lt;/domain&gt;\n &lt;adkim&gt;r&lt;/adkim&gt;\n &lt;aspf&gt;r&lt;/aspf&gt;\n &lt;p&gt;quarantine&lt;/p&gt;\n &lt;pct&gt;100&lt;/pct&gt;\n &lt;/policy_published&gt;\n &lt;record&gt;\n &lt;row&gt;\n &lt;source_ip&gt;192.134.164.83&lt;/source_ip&gt;\n &lt;count&gt;4&lt;/count&gt;\n &lt;policy_evaluated&gt;\n &lt;disposition&gt;quarantine&lt;/disposition&gt;\n &lt;dkim&gt;fail&lt;/dkim&gt;\n &lt;spf&gt;fail&lt;/spf&gt;\n &lt;/policy_evaluated&gt;\n &lt;/row&gt;\n &lt;identifiers&gt;\n &lt;header_from&gt;recoil.org&lt;/header_from&gt;\n &lt;/identifiers&gt;\n &lt;auth_results&gt;\n &lt;dkim&gt;\n &lt;domain&gt;inria.fr&lt;/domain&gt;\n &lt;selector&gt;dc&lt;/selector&gt;\n &lt;result&gt;pass&lt;/result&gt;\n &lt;/dkim&gt;\n &lt;spf&gt;\n &lt;domain&gt;inria.fr&lt;/domain&gt;\n &lt;result&gt;pass&lt;/result&gt;\n &lt;/spf&gt;\n &lt;/auth_results&gt;\n &lt;/record&gt;\n&lt;/feedback&gt;\n</code></pre>\n<p>A little bit of sleuthing on that IP shows that:</p>\n<pre><code>$ host 192.134.164.83\n83.164.134.192.in-addr.arpa domain name pointer mail2-relais-roc.national.inria.fr\n</code></pre>\n<p>...it's the INRIA email server, which probably means that I sent an\nemail to 'caml-devel@inria.fr' from 'anil@recoil.org', which proceeded to forward\nthat to a recipient hosted on a '@yahoo.com' email address which failed\nverification since it hadn't come straight from recoil. Note that both\nSPF failed (to be expected since the INRIA server would have sent the email)\nbut <em>also</em> DKIM failed (since the INRIA server probably rewrote some mail headers).</p>\n<p>This is all quite complex sounding (and it is), but it is invaluable to help\ndebug the distributed system over time.\nI run a quick OCaml script over the various emails that are coming in, and steadily\ntelling our users when I need to reconfigure one of their clients.\nDMARC reporting itself has had some security implications. A <a href=\"https://www.usenix.org/conference/usenixsecurity23/presentation/ashiq\">2023 study</a> demonstrated that a single attacker email can be\nturned into a flood of DMARC reports. The fix was to lock down the acceptable\n<code>rua</code> addresses to the domain itself, so it doesn't apply to our self hosting setup.</p>\n<h3 id=\"srs-to-keep-email-forwarding-working\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#srs-to-keep-email-forwarding-working\"></a>SRS to keep email forwarding working</h3>\n<p>There's one painful corner case that I identified above that I haven't quite sorted\nyet: mailing lists. This is why we're still in 'quarantine' mode for our Recoil setup.\nIf someone emails <code>anil@recoil.org</code> and my server forwards it on to (say) my\nCambridge address, the original sender's domain is now being sent to the\ndestination from our IP, which will fail their SPF check.</p>\n<p>The fix is the <a href=\"https://www.open-spf.org/SRS/\">Sender Rewriting Scheme</a> (SRS),\nimplemented by <a href=\"https://github.com/roehling/postsrsd\">postsrsd</a>. Using this, we rewrite\nthe envelope sender on the way out from <code>original@example.com</code> to\nsomething like <code>SRS0=…=example.com=original@recoil.org</code>, so that SPF checks at the\ndestination evaluates against our domain. We also reverse the rewrite on the way back for any bounces.</p>\n<p>SRS doesn't seem to have an IETF RFC that I can find, but it does let some\nforwarding paths survive in this DMARC-enforced world. I'm still figuring out exactly how it all\nworks in our especially complex Cambridge email setup (which involves many hoops and\nforwarding layers), but this is all it takes in the Postfix setup for now:</p>\n<pre><code class=\"language-ini\"># /etc/postfix/main.cf\nsender_canonical_maps = socketmap:unix:srs:forward\nrecipient_canonical_maps = socketmap:unix:srs:reverse\n</code></pre>\n<p>Phew, so with SPF, DKIM, DMARC and SRS wired up, our deliverability index against\nGmail and Outlook seems reliable. Not one of our (loudly complaining) families has\ncomplained about spam since we switched to this setup. Hurrah!</p>\n<h2 id=\"accessing-email-for-users\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#accessing-email-for-users\"></a>Accessing email for users</h2>\n<p>Now that we can both send and receive email, all that's left is for users to be\nable to access it easily! On Recoil it comes down to two paths: a regular IMAP\nclient (e.g. Mail.app on macOS) talking to our Dovecot server, or via a\nweb browser pointing at our self-hosted webmail (which itself acts as an IMAP\nclient).</p>\n<h3 id=\"dovecot-and-imap\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#dovecot-and-imap\"></a>Dovecot and IMAP</h3>\n<p><a href=\"https://www.dovecot.org/\">Dovecot</a> handles all the mailbox access on <code>pork</code>,\nencrypting listeners with TLS (<a href=\"https://www.rfc-editor.org/rfc/rfc8314\">RFC 8314</a>).\nAll our ports require TLS so no plaintext mail or passwords ever cross the\npublic network. We use <a href=\"https://letsencrypt.org/\">LetsEncrypt</a> for this, with\nmultiple host aliases (like <code>imap.recoil.org</code> or <code>smtp.recoil.org</code>) served via\n<a href=\"https://en.wikipedia.org/wiki/Server_Name_Indication\">SNI</a> so that our users\nwho last configured their phones in 2008 don't have to touch anything:</p>\n<pre><code class=\"language-ini\"># /etc/dovecot/conf.d/11-ssl-imap.conf\nlocal_name imap.recoil.org {\n ssl_server_cert_file = /etc/letsencrypt/live/imap.recoil.org/fullchain.pem\n ssl_server_key_file = /etc/letsencrypt/live/imap.recoil.org/privkey.pem\n}\n</code></pre>\n<p>Dovecot also pulls double duty as Postfix's SASL backend (<a href=\"https://www.rfc-editor.org/rfc/rfc4422\">RFC\n4422</a>) for outbound submission. This\nallows users to have the same password for IMAP (to access their email) and\nSMTP (to send email).</p>\n<h3 id=\"roundcube-webmail\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#roundcube-webmail\"></a>Roundcube webmail</h3>\n<p>I used to work on <a href=\"https://anil.recoil.org/notes/horde-developer\">Horde IMP back in the 2000s</a>, and so\nI did try to get my beloved <a href=\"https://github.com/horde/imp/\">IMP webmail</a> running again. However, it looks\nlike it's between release cycles right now and things are in flux,\nso I switched over to running\n<a href=\"https://roundcube.net/\">Roundcube</a> behind a\n<a href=\"https://caddyserver.com/\">Caddy</a> TLS reverse proxy, all packaged together\nas a Docker Compose service.</p>\n<p>Roundcube is configured to connect to\n<code>pork</code> over the same TLS/IMAP as any other client would:</p>\n<pre><code class=\"language-yaml\">services:\n roundcube:\n image: roundcube/roundcubemail\n environment:\n ROUNDCUBEMAIL_DEFAULT_HOST: ssl://pork.recoil.org\n ROUNDCUBEMAIL_SMTP_SERVER: tls://pork.recoil.org\n ROUNDCUBEMAIL_PLUGINS: \"managesieve,markasjunk,archive\"\n caddy:\n image: caddy:latest\n</code></pre>\n<p>The Roundcube plugins I'm using are:</p>\n<ul>\n<li><a href=\"https://plugins.roundcube.net/packages/kolab/managesieve\"><code>managesieve</code></a>\nthat speaks the <a href=\"https://www.rfc-editor.org/rfc/rfc5804\">ManageSieve</a> protocol\nto allow editing a Sieve filter in the browser.</li>\n<li><a href=\"https://plugins.roundcube.net/packages/johndoh/markasjunk\"><code>markasjunk</code></a> translates the \"Junk button\" in the webmail into a move to the Junk folder that causes the ham/spam classification to function invisibly to the user.</li>\n</ul>\n<p><img alt=\"%c\" src=\"https://anil.recoil.org/images/roundcube-ss-filter.webp\" title=\"The Roundcube ManageSieve UI doesn't expose the raw Sieve DSL, so it's easier to use\"/></p>\n<h2 id=\"what-else-is-left-to-do\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#what-else-is-left-to-do\"></a>What else is left to do?</h2>\n<p>This setup has been pretty solid for day-to-day use in the past few weeks, but\nthere is (always) more work to do.</p>\n<p>As a recap, here's the list of DNS records <code>recoil.org</code> publishes\nto make everything work:</p>\n<div role=\"region\"><table>\n<tr>\n<th>Record</th>\n<th>Purpose</th>\n<th>Reference</th>\n</tr>\n<tr>\n<td><code>MX (pork.recoil.org)</code></td>\n<td>Where mail for the domain is delivered</td>\n<td><a href=\"https://www.rfc-editor.org/rfc/rfc5321\">RFC 5321 §5</a></td>\n</tr>\n<tr>\n<td><code>A</code> / <code>AAAA</code></td>\n<td>pork's IP addresses</td>\n<td><a href=\"https://www.rfc-editor.org/rfc/rfc1035\">RFC 1035</a></td>\n</tr>\n<tr>\n<td><code>PTR</code> (rDNS)</td>\n<td>IP to <code>pork.recoil.org</code> reverse mapping</td>\n<td><a href=\"https://www.rfc-editor.org/rfc/rfc1912\">RFC 1912 §2.1</a></td>\n</tr>\n<tr>\n<td><code>TXT</code> SPF (<code>v=spf1 mx -all</code>)</td>\n<td>Which hosts may send for the domain</td>\n<td><a href=\"https://www.rfc-editor.org/rfc/rfc7208\">RFC 7208</a></td>\n</tr>\n<tr>\n<td><code>TXT</code> DKIM (<code>mail._domainkey</code>)</td>\n<td>Public key for signature verification</td>\n<td><a href=\"https://www.rfc-editor.org/rfc/rfc6376\">RFC 6376</a></td>\n</tr>\n<tr>\n<td><code>TXT</code> DMARC (<code>_dmarc</code>)</td>\n<td>Policy and reporting for SPF/DKIM alignment</td>\n<td><a href=\"https://www.rfc-editor.org/rfc/rfc7489\">RFC 7489</a></td>\n</tr>\n</table></div><h3 id=\"modern-transport-security-mta-sts-dane-and-dnssec\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#modern-transport-security-mta-sts-dane-and-dnssec\"></a>Modern transport security: MTA-STS, DANE and DNSSEC</h3>\n<p><a href=\"https://www.rfc-editor.org/rfc/rfc8461\">MTA-STS</a> tells other\nmail servers they should only talk to us over TLS with a valid\ncertificate. This mitigates the <a href=\"https://nostarttls.secvuln.info/\">STARTTLS-downgrade attack</a>\nwhereby an attacker strips the TLS upgrade from the SMTP session. It also\nhelps that email between servers is guaranteed to be TLS encrypted so that\ncasual network snooping can no longer read emails.</p>\n<p><a href=\"https://www.rfc-editor.org/rfc/rfc7672\">DANE/TLSA</a> adds support for\nDNS-pinned TLS certificate hashes, rather than using HTTPS for this. The\ndelay in deploying this is that DANE requires the DNS zone to be DNSSEC-signed, which <code>recoil.org</code>\nisn't yet. Moving a domain to DNSSEC requires understanding a lot more about\nkey rotation than I have time for right now, but it's getting higher up on my\nTODO list!</p>\n<p><a href=\"https://www.open-spf.org/SRS/\">SRS</a> is semi-deployed right now,\nbut I haven't tested it against every forwarding path that\nexists in our setup. In particular, the INRIA failure is a bit worrying as\nit triggers a DMARC failure (and hence might affect our domain reputation), but\ninvolves an email server out of my immediate control.</p>\n<h3 id=\"a-jmap-proxy-in-ocaml\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#a-jmap-proxy-in-ocaml\"></a>A JMAP proxy in OCaml?</h3>\n<p>I'd also like to expose email access via\n<a href=\"https://www.rfc-editor.org/rfc/rfc8620\">JMAP</a> (the JSON Mail Access\nProtocol, <a href=\"https://www.rfc-editor.org/rfc/rfc8620\">RFC 8620</a>\nand <a href=\"https://www.rfc-editor.org/rfc/rfc8621\">RFC 8621</a>)\nJMAP is a much nicer fit for modern network clients than IMAP is, as\nit uses more widely deployed protocols and formats like HTTPS and JSON.</p>\n<p>However, Dovecot doesn't speak JMAP natively, and the only standalone JMAP servers I've\nevaluated (like Stalwart) all want to own the mailbox storage themselves, which would mean giving up Maildir.\nI'm not quite willing to give up the simplicity of that email storage just yet...</p>\n<p>The plan I'm considering is to put my <a href=\"https://anil.recoil.org/notes/aoah-2025-17\">OCaml JMAP implementation</a> in front of Dovecot as a translating proxy.\nJMAP requests would come in over HTTPS, get mapped to IMAP calls, and the responses can be sent back as JSON.\nThis also gives me an excuse to stress-test my OCaml JMAP code against real traffic. Stay tuned!</p>\n<h2 id=\"is-this-a-negative-result-for-self-hosting\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#is-this-a-negative-result-for-self-hosting\"></a>Is this a negative result for self-hosting?</h2>\n<p>It is rather unfortunate that \"running an email server\" in 2026 means\ngetting at least six separate DNS records correct before reliably sending or\nreceiving email. And securing an IPv4 block allocation from RIPE took <a href=\"https://github.com/samoht\">Thomas Gazagnaire</a> almost a year.\nAnd keeping all this up-to-date is a fair bit of work with respect to security,\nbut both <a href=\"https://nick.recoil.org\">Nick Ludlam</a> and I use this (along with our friends and family who have accounts)\nso it's for a small group of people.</p>\n<p>The upside though, is what an excellent learning process it is to go through to\nget up to speed on how the modern Internet really works. Email these days can\nreset almost any aspect of our digital lives, and so it feels important to\nmaintain some semblance of agency over how it works. And it is quite\nheartwarming that it's still possible to do on the Internet as a small outfit\nwithout requiring any central authority to approve it!</p>\n<p>The other thing I'm increasingly conscious of is that \"secure\" is a moving\ntarget. Self-hosted services like ours have always faced opportunistic bot\nscans, but the <a href=\"https://anil.recoil.org/notes/internet-immune-system\">autonomous chaining of vulnerabilities by frontier AI models</a> has completely shifted the threat model.</p>\n<p>The gap between a CVE being published and a working exploit being thrown at every\nSMTP/IMAP listener on the public Internet is now probably measured in hours and not\nweeks. Most of the hardening choices in this post; pinning Postfix to\nspecific addresses, isolating the webmail in containers on a separate IP,\ngreylisting and DNSBLs before handling email, are all pretty conventional\ndecisions to get some security in depth.\nIt does make me want to push way harder towards the dynamic antibotty-style active defences\nI <a href=\"https://anil.recoil.org/papers/2025-internet-ecology\">sketched out last year</a>...</p>\n<p><img alt=\"%c\" src=\"https://anil.recoil.org/images/moving-recoil-9.webp\" title=\"Running your email server will occasionally result in your zooming through central London in a white van desperately trying to stop it flying out of a window (me, Nick Ludlam, James Cronin, 2002)\"/></p>\n<p>I hope that this guide might come in useful to anyone else who wants to have a go! I'm particularly excited by projects like <a href=\"https://ryan.freumh.org/talks/2026-fosdem-eilean.html\">Eilean</a> which make this process more of a single-click process to deploy. Over the course of the next few months, I plan to write about how we're self hosting <em>other</em> services like photos, chat, location and more; see also our <a href=\"https://anil.recoil.org/notes/mirage-self-hosting\">self-hosted MirageOS website</a> or my <a href=\"https://anil.recoil.org/notes/owntracks-and-lifecycle\">OwnTracks location stack</a> for some background. It's good fun!</p><h1>References</h1><ul><li>Madhavapeddy et al (2025). Steps towards an Ecology for the Internet. Association for Computing Machinery. <a href=\"https://doi.org/10.1145/3744169.3744180\" target=\"_blank\"><i>10.1145/3744169.3744180</i></a></li>\n<li>Scott et al (2010). Using functional programming within an industrial product group: perspectives and perceptions. ACM. <a href=\"https://doi.org/10.1145/1863543.1863557\" target=\"_blank\"><i>10.1145/1863543.1863557</i></a></li>\n<li>Madhavapeddy (2025). Tracking locations with OwnTracks, Life Cycle and Home Assistant. <a href=\"https://doi.org/10.59350/13ras-yd957\" target=\"_blank\"><i>10.59350/13ras-yd957</i></a></li>\n<li>Madhavapeddy (2026). Rewilding the Web: my workshop report from Edinburgh. <a href=\"https://doi.org/10.59350/g40yy-ks003\" target=\"_blank\"><i>10.59350/g40yy-ks003</i></a></li>\n<li>Madhavapeddy (2026). The Internet needs an antibotty immune system, stat. <a href=\"https://doi.org/10.59350/snnnf-asc02\" target=\"_blank\"><i>10.59350/snnnf-asc02</i></a></li>\n<li>Madhavapeddy (2025). Jane Street and Docker on moving to OCaml 5 at ICFP/SPLASH 2025. <a href=\"https://doi.org/10.59350/3jkaq-d3398\" target=\"_blank\"><i>10.59350/3jkaq-d3398</i></a></li>\n<li>Doan et al (2022). An Empirical View on Consolidation of the Web. <a href=\"https://doi.org/10.1145/3503158\" target=\"_blank\"><i>10.1145/3503158</i></a></li>\n<li>Ramanathan et al (2020). Quantifying the Impact of Blocklisting in the Age of Address Reuse. ACM. <a href=\"https://doi.org/10.1145/3419394.3423657\" target=\"_blank\"><i>10.1145/3419394.3423657</i></a></li>\n<li>Ziv et al (2021). ASdb: a system for classifying owners of autonomous systems. ACM. <a href=\"https://doi.org/10.1145/3487552.3487853\" target=\"_blank\"><i>10.1145/3487552.3487853</i></a></li>\n<li>Olea et al (2025). Evaluating Phishing Email Efficacy. ACM. <a href=\"https://doi.org/10.1145/3716489.3728437\" target=\"_blank\"><i>10.1145/3716489.3728437</i></a></li></ul>",
12
11
  "contributors": [
13
12
  {
14
13
  "type": "Person",
@@ -18,9 +17,9 @@
18
17
  "family_name": "Madhavapeddy",
19
18
  "affiliations": [
20
19
  {
21
- "name": "University of Cambridge",
22
20
  "identifier": "https://ror.org/013meh722",
23
- "identifier_type": "ROR"
21
+ "identifier_type": "ROR",
22
+ "name": "University of Cambridge"
24
23
  }
25
24
  ]
26
25
  },
@@ -29,6 +28,7 @@
29
28
  ]
30
29
  }
31
30
  ],
31
+ "title": "Self-hosting email the hard way from your own routable IPv4 block up",
32
32
  "date_published": "2026-06-06T00:00:00Z",
33
33
  "date_updated": "2026-06-12T10:13:57Z",
34
34
  "description": "Fresh from rewilding the web, I updated the Recoil self-hosting infrastructure that Nick Ludlam and I have run since 1997. Most exciting, this is now 'email the hard way' that includes getting our very own dedicated IPv4 allocation routed thanks to my buddy Thomas Gazagnaire helping out from France! This post will be quite technical, aimed at those interested in building their own email stack. We'll talk about why bother running your own email;",
@@ -52,6 +52,12 @@
52
52
  "url": "https://anil.recoil.org/images/moving-recoil-9.webp"
53
53
  }
54
54
  ],
55
+ "identifiers": [
56
+ {
57
+ "identifier": "https://doi.org/10.59350/gj8re-sca95",
58
+ "identifier_type": "GUID"
59
+ }
60
+ ],
55
61
  "language": "en",
56
62
  "license": {
57
63
  "id": "CC-BY-4.0",
@@ -110,7 +116,6 @@
110
116
  "type": "IsPartOf"
111
117
  }
112
118
  ],
113
- "schema_version": "https://commonmeta.org/commonmeta_v1.0rc2.json",
114
119
  "subjects": [
115
120
  {
116
121
  "id": "https://openalex.org/subfields/1702",
@@ -132,7 +137,8 @@
132
137
  "subject": "Email"
133
138
  }
134
139
  ],
135
- "title": "Self-hosting email the hard way from your own routable IPv4 block up",
136
140
  "url": "https://anil.recoil.org/notes/recoil-self-hosting-2026",
137
- "version": "v1"
141
+ "version": "v1",
142
+ "content": "<p>Fresh from <a href=\"https://anil.recoil.org/notes/rewilding-the-web-report\">rewilding the web</a>, I updated the Recoil self-hosting\ninfrastructure that <a href=\"https://nick.recoil.org\">Nick Ludlam</a> and I have run since 1997. Most exciting, this is now\n'email the hard way' that includes getting our very own dedicated IPv4 allocation routed thanks\nto my buddy <a href=\"https://github.com/samoht\">Thomas Gazagnaire</a> helping out from France!</p>\n<p>This post will be quite technical, aimed at those interested in building their own email stack. We'll talk about <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#why-run-your-own-email\">why bother</a> running your own email; <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#email-receipt\">receiving email</a> (covering <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#ip-reputation-and-denylists\">IP reputation</a>, <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#getting-our-very-own-ipv4-address-block\">getting our own IPv4 allocation</a>, <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#stopping-the-zombie-spam-horde\">stopping bots</a>, and <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#delivering-email-with-lmtp-and-sieve\">Sieve delivery</a>); <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#sending-email-to-other-people\">sending email</a> reliably with <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#spf-describes-who-can-send-email-for-a-domain\">SPF</a>, <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#dkim-cryptographic-signing-of-outbound-mail\">DKIM</a>, <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#dmarc-ties-it-together-and-provides-reporting-stats\">DMARC</a> and <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#srs-to-keep-email-forwarding-working\">SRS</a>; <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#accessing-email-for-users\">user access</a> via Dovecot IMAP and Roundcube webmail; and finally <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#what-else-is-left-to-do\">what's left to do</a> and a <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#is-this-a-negative-result-for-self-hosting\">reflection</a> on future research ideas.</p>\n<h2 id=\"why-run-your-own-email\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#why-run-your-own-email\"></a>Why run your own email?</h2>\n<p>For someone just getting into systems and networking, it's a hugely educational\nexperience. Running my own servers has been how I've learnt how the Internet\nworks, and how I got into open-source back in the day, by <a href=\"https://anil.recoil.org/notes/openbsd-developer\">installing OpenBSD</a> and <a href=\"https://anil.recoil.org/notes/commit-access-to-php\">fixing bugs in PHP</a>!</p>\n<p>More broadly, self-hosting is important for sovereign access to your own data\nas the <a href=\"https://doi.org/10.1145/3503158\">web steadily consolidates</a> among a few\nplayers. A <a href=\"https://www.netmeister.org/blog/mx-diversity.html\">2023 analysis</a>\nshowed that two companies can mostly read all of your email traffic:</p>\n<blockquote>\n<p>So all in all, the answer to the question of who can read your email pretty\nmuch boils down to -- yep -- \"Google and Microsoft\". Even if your domain\ndoesn't use one of their mail servers, chances are that <a href=\"https://mako.cc/copyrighteous/google-has-most-of-my-email-because-it-has-all-of-yours\">whoever you are sending mail to does</a>.\n<cite>-- <a href=\"https://www.netmeister.org/blog/mx-diversity.html\">MX diversity</a>, <a href=\"https://mstdn.social/@jschauma\">Jan Schaumann</a>, 2023</cite></p>\n</blockquote>\n<p>Email is right at the centre of our digital lives; consider just how many online\naccounts you could reset if your email got <a href=\"https://dl.acm.org/doi/10.1145/3716489.3728437\">hijacked or phished</a>. In many ways, it's <em>the</em> online service that connects up all the other ones.</p>\n<h3 id=\"should-you-run-your-own-email\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#should-you-run-your-own-email\"></a>Should you run your own email?</h3>\n<p>Hosting your email is a fair bit of work, but that's spread over a long period of time as you keep an eye on it. <a href=\"https://nick.recoil.org\">Nick Ludlam</a> and I started our hosting adventure back in 1998 or so when we <a href=\"https://anil.recoil.org/notes/netapp-tr-3071-1\">worked at NASA</a> for the summer. The first time we did a major server move back in 2002, here's Nick and <a href=\"https://nanog.org/events/nanog-48/content/2951/\">Chris Luke</a>\nloading up our second server into a dodgy white minivan to host in Easynet, right outside <a href=\"https://en.wikipedia.org/wiki/Cyberia,_London\">London's first Internet cafe</a>.</p>\n<p><img alt=\"%c\" src=\"https://anil.recoil.org/images/moving-recoil-1.webp\" title=\"From Cyberia outside Fitzrovia over to Brick Lane and Easynet hosting! (2002)\"/></p>\n<p>If you do decide to have a go for yourself, you'll need to find stable Internet hosting (your own home network isn't the best choice,\nfor reasons we'll see later in this post), and also build up a reputation. Luckily, finding stable Internet is <em>much</em> easier these days than it was in the late 90s, and we use <a href=\"https://mythic-beasts.com\">Mythic Beasts</a> who are excellent, reliable and local.</p>\n<p>I'll explain in this post how we obtained our own dedicated IPv4 address block\nto help build up a high deliverability index for our personal email that's\nentirely independent! While email looks like one service to most users, it\nis actually three separate activities: <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#email-receipt\">email receipt</a>, <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#sending-email-to-other-people\">email\nsubmission</a>, and <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#accessing-email-for-users\">email access</a>. I'll dive into how each of\nthese work on Recoil now, in case it's useful for your own setup.</p>\n<h2 id=\"email-receipt\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#email-receipt\"></a>Email receipt</h2>\n<p>Each domain (like <code>recoil.org</code>) on the Internet runs an SMTP server that allows\nit to receive email from other servers. You can query this server for any\ndomain by looking up its 'MX' DNS record:</p>\n<pre><code class=\"language-bash\">$ host -t mx recoil.org\nrecoil.org mail is handled by 10 pork.recoil.org.\n</code></pre>\n<p>This indicates that the Internet host <code>pork.recoil.org</code> must accept connections from any server\non the internet that wishes to deliver email to <code>&lt;email&gt;@recoil.org</code>.\nThe core difficulty is that <a href=\"https://www.rfc-editor.org/rfc/rfc5321\">SMTP</a> (as\ndesigned in the more trusting 1980s) doesn't mandate a built-in proof of trust.\nAnyone can claim to be anyone else, and the 'sender' in the email we receive\ncan be trivially forged.</p>\n<p>The IETF's <a href=\"https://datatracker.ietf.org/doc/html/rfc2505\">response</a> was to accrete a stack of checks over the years that an\nemail sender must pass, or be filtered by the recipient. If we mess these\nidentity checks up, then our email won't get delivered reliably across the\nInternet and the service won't be very useful.</p>\n<h3 id=\"ip-reputation-and-denylists\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#ip-reputation-and-denylists\"></a>IP reputation and denylists</h3>\n<p>Spam was cheap to send from anywhere on the Internet, and so naturally grew as\nthe wider network gained adoption. Paul Vixie came up with a <a href=\"https://en.wikipedia.org/wiki/Domain_Name_System_blocklist\">DNS-based\nblocklist</a> back in\n1997. Since then, many more have sprung up, operated by organisations like\n<a href=\"https://www.spamhaus.org/\">Spamhaus</a>, <a href=\"https://www.spamcop.net/\">Spamcop</a> and\n<a href=\"https://www.barracudacentral.org/rbl\">Barracuda</a> that aggregate reports about\nbotnets, compromised hosts and spammers into lists that any email server can\nconsult.</p>\n<p>The DNS blacklist/whitelist protocol (<a href=\"https://www.rfc-editor.org/rfc/rfc5782\">RFC 5782</a>) is super simple and can be queried right from your command line:</p>\n<pre><code class=\"language-bash\">$ dig 2.0.0.127.zen.spamhaus.org +short # testing address\n127.0.0.2\n127.0.0.10\n127.0.0.4\n</code></pre>\n<p>That's a localhost testing address, but the presence of a DNS record in the RBL\nmeans that that server is suspect. This is where having control of your own\nIPv4 address really pays off. Reputation for email via these RBLs accrues\nagainst the address and not the domain. In theory, the IP address you use for\nyour own self-hosted email <a href=\"https://doi.org/10.1145/3419394.3423657\">might have been re-used</a> from a cloud provider\nby someone else, and therefore be tainted by other people's bad behaviour.</p>\n<p><em>(Update: <a href=\"https://ryan.freumh.org\">Ryan Gibb</a> observes that as these lists often work on IP blocks, your IP neighbour can also effect your reputation in a multitenant cloud. He reports that he uses <a href=\"https://www.hetzner.com/\">Hetzner</a> for his use who <a href=\"http://bef.no/DitchingWindowsAndAWS/\">manually allowlist</a> SMTP servers to minimise abuse).</em></p>\n<h3 id=\"getting-our-very-own-ipv4-address-block\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#getting-our-very-own-ipv4-address-block\"></a>Getting our very own IPv4 address block</h3>\n<p>In contrast, a fresh IPv4 starts neutral and earns its reputation through\nconsistent, well-formed email sending. The only way to fully control this is\nto control the address space itself, which is why Team Recoil now have our very\nown IPv4 address block: <code>185.33.27.0/24</code>!</p>\n<p>Getting our very own address allocation involved joining a very long queue due to IPv4\nexhaustion. The <a href=\"https://www.ripe.net/\">RIPE NCC</a> is the regional registrar for\nEurope and ran out of unallocated IPv4 space in <a href=\"https://www.ripe.net/publications/news/the-ripe-ncc-has-run-out-of-ipv4-addresses/\">November\n2019</a>,\nand since then the only way to get an allocation directly from RIPE is via a\n<a href=\"https://www.ripe.net/manage-ips-and-asns/ipv4/\">waiting list</a> for small allocations.</p>\n<blockquote>\n<p>While we have run out of IPv4 addresses, RIPE NCC members can still request a\nsingle /24 allocation (256 addresses). [...] Requests are added to a waiting\nlist and processed when we recover IPv4 addresses in the future. [...] This\nis only available to LIRs that have never received an IPv4 allocation from\nthe RIPE NCC before (of any size).\n<cite>-- <a href=\"https://www.ripe.net/manage-ips-and-asns/ipv4/\">RIPE NCC, /24 Allocation via the Waiting List</a></cite></p>\n</blockquote>\n<p>I got into that queue from the UK, and my buddy <a href=\"https://github.com/samoht\">Thomas Gazagnaire</a> did the same from France.\nHe got to the head of his queue well ahead of me, and we got allocated our\nown <code>/24</code> block after about a six month wait.</p>\n<h4 id=\"signing-up-to-ripe-for-yourself\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#signing-up-to-ripe-for-yourself\"></a>Signing up to RIPE for yourself</h4>\n<p>If you want to do this yourself and are based in Europe, then pay the annual RIPE NCC membership fee to open a\n<a href=\"https://www.ripe.net/membership/become-a-member/\">Local Internet Registry</a>\n(LIR) account.</p>\n<p>After that, confirm you've never previously received an IPv4 allocation,\njoin the waiting list, and wait until enough addresses are recovered (e.g. from\ndefunct LIRs, returned space, or revoked allocations) for a slot to come\nup. The wait is currently a year or two I think and seems to depend which country you're in.</p>\n<p><a href=\"https://www.ripe.net/manage-ips-and-asns/ipv4/ipv4-waiting-list/\"> <img alt=\"%c\" src=\"https://anil.recoil.org/images/ripe-ss-2.webp\" title=\"RIPE LIR waiting list statistics\"/> </a></p>\n<h4 id=\"setting-up-ipv4-routes-for-an-autonomous-system\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#setting-up-ipv4-routes-for-an-autonomous-system\"></a>Setting up IPv4 routes for an autonomous system</h4>\n<p>The next step was to route this allocation to an actual machine hooked up\nto the public Internet. While it is possible to <a href=\"https://en.wikipedia.org/wiki/Border_Gateway_Protocol\">advertise our own routes</a>, in the\ninterests of expediency we decided to request <a href=\"http://mythic-beasts.com\">Mythic Beasts</a> to take care of it for us and handle the peering.</p>\n<p>The procedure to do this via RIPE is straightforward. The IPv4 block is given\nan assignment by creating a '<a href=\"https://en.wikipedia.org/wiki/Resource_Public_Key_Infrastructure\">RPKI ROA</a>' in\ntheir database. This is a PKI chain-of-trust used to connect up IP routing blocks on the\nInternet.</p>\n<p><img alt=\"%c\" src=\"https://anil.recoil.org/images/ripe-ss-1.webp\"/></p>\n<p>An <a href=\"https://en.wikipedia.org/wiki/Autonomous_system_(Internet)\">Autonomous System</a>\n(AS) is a unit of independent routing policy on the Internet and announced to the rest of the world via\n<a href=\"https://www.rfc-editor.org/rfc/rfc4271\">BGP</a>.\nWorking out who actually owns a given ASN turns out to be surprisingly hard as the WHOIS\ndatabases are inconsistently updated, but <a href=\"https://doi.org/10.1145/3487552.3487853\">third-party databases exist</a>\nto help.\nIn our case <a href=\"https://ipinfo.io/AS44684/185.33.27.0/24\">our IP block</a> is connected to the <a href=\"https://ipinfo.io/AS44684\">Mythic Beasts AS44684</a>.</p>\n<p>Once that was sorted, RIPE once again uses DNS to announce the connection to Mythic:</p>\n<pre><code class=\"language-bash\">$ dig soa -x 185.33.27.0 @pri.authdns.ripe.net +noall +authority\n27.33.185.in-addr.arpa.\t86400\tIN\tNS\tns1.mythic-beasts.com.\n27.33.185.in-addr.arpa.\t86400\tIN\tNS\tns2.mythic-beasts.com.\n</code></pre>\n<p>Another nice aspect is being able to control our own reverse DNS to this IP block, which is another\nimportant signal for email reputation:</p>\n<pre><code class=\"language-bash\">$ host pork.recoil.org\npork.recoil.org has address 185.33.27.128\npork.recoil.org has IPv6 address 2a00:1098:39c::3\n$ host 185.33.27.128\n128.27.33.185.in-addr.arpa domain name pointer pork.recoil.org.\n</code></pre>\n<h3 id=\"stopping-the-zombie-spam-horde\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#stopping-the-zombie-spam-horde\"></a>Stopping the Zombie spam horde</h3>\n<p>We've so far gone to an enormous amount of hassle to get a clean IPv4 block,\nbut this is necessary but not sufficient to protect ourselves! We also need to\nconfigure our server to defend itself against the zombie botnet hordes.</p>\n<p>The overwhelming majority of incoming TCP connections on port 25\nare botnets attempting to deliver spam, probe for open relays, guess\ncredentials (or more recently) harvest data for AI training.\nSpending CPU cycles parsing the body of every one of these requests is both\nwasteful and dangerous, so a good setup will try to filter these out as early as\npossible.</p>\n<p>We first deploy Postfix's <a href=\"https://www.postfix.org/POSTSCREEN_README.html\">postscreen</a>, which\nlistens on port 25 as the first port of call. Architecturally it's a protocol proxy that accepts the\nTCP connection, runs a battery of cheap checks:</p>\n<ul>\n<li>DNSBL lookups in parallel from multiple providers</li>\n<li>adds a deliberate pre-greet pause from <a href=\"https://www.rfc-editor.org/rfc/rfc5321\">RFC 5321 §3.1</a> that catches\nbots which start talking before the server's banner appears</li>\n<li>a couple of pipelining and non-SMTP-command tests to check for compliance</li>\n</ul>\n<p>It only hands the connection off to the real <code>smtpd</code> process if the client looks legitimate after\nthese. Bad clients are dropped during the pre-greet pause with a temporary failure, which\nwill encourage false positives to retry in the future.\nInterestingly this proxy is exactly what <a href=\"https://anil.recoil.org/notes/cacm-docker-cover\">we do in Docker for Desktop</a>, where a userspace <a href=\"https://anil.recoil.org/notes/icfp25-ocaml5-js-docker\">OCaml VPNKit proxy</a> mediates between containers and the host network without exposing the host stack directly. I'm going to reimplement postscreen in OxCaml soon...</p>\n<p>For those configuring your own server, the relevant Postfix knobs are in <code>main.cf</code>:</p>\n<pre><code class=\"language-ini\">postscreen_dnsbl_sites = zen.spamhaus.org*3 bl.spamcop.net*2 b.barracudacentral.org*2\npostscreen_dnsbl_action = enforce # reject when DNSBL score crosses threshold\npostscreen_greet_action = enforce # reject pre-greet slammers\n</code></pre>\n<p>The <code>*3</code> and <code>*2</code> weights let us combine blocklists rather than trust any single\nsource; in the above we trust Spamhaus alone and the two weaker lists need to both agree to\nreject.\nJust postscreen by itself seems to reject over 90% of incoming spam requests, but there's\nanother trick we can apply.</p>\n<p>One minor wrinkle that <a href=\"https://nick.recoil.org\">Nick Ludlam</a> noticed is that Apple's iCloud email service interacts\nbadly with postscreen. Apple's outbound MX pool that delivers email doesn't\nretry from the same IP, which means postscreen's allowlist for that connection\nnever matches and mail can stay in limbo for hours. This isn't really a\nmisconfiguration on our end, but it affect users badly.</p>\n<p>The fix (since Apple still owns the whole of <code>17.0.0.0/8</code>!) is to allowlist that whole range up front in\n<code>postscreen_access.cidr</code> so it bypasses the protocol tests entirely. The\n<a href=\"https://docs.mailcow.email/manual-guides/Postfix/u_e-postfix-postscreen_whitelist/\">mailcow allowlist guide</a>\nwalked me through the syntax in case you hit the same problem:</p>\n<pre><code class=\"language-bash\"># /etc/postfix/postscreen_access.cidr\n17.0.0.0/8 permit # Apple iCloud MX pool is somewhere in here\n</code></pre>\n<h4 id=\"greylisting\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#greylisting\"></a>Greylisting</h4>\n<p>Once a connection survives postscreen, our next defensive layer is\ngreylisting (<a href=\"https://www.rfc-editor.org/rfc/rfc6647\">RFC 6647</a>),\nimplemented for us by <a href=\"https://rspamd.com/\">rspamd</a>. The idea is that\nthe first time we ever see a particular source, our server returns a temporary failure rather\nthan accepting the message, and records the source for future reference.</p>\n<p>Legitimate MTAs are required by <a href=\"https://www.rfc-editor.org/rfc/rfc5321\">RFC 5321</a> to queue/retry after a\nfew minutes. When the retry comes in, we'll have seen it from the first attempt\nand then let the message through. A large fraction of botnets are\n\"single-shot\" delivery engines that just move on to the next victim when they\nget a failure, since maintaining a retry queue is expensive when you're\nshotgunning millions of messages. The cost to a <em>real</em> sender is a one-time\ndelay of a few minutes, which is amortised across all users from that source\n(the greylisting only happens to the first sender).</p>\n<p>All these mechanics are implemented in rspamd, which itself has a nice local connection: its author\n<a href=\"https://github.com/vstakhov\">Vsevolod Stakhov</a> did his PhD in the CL with <a href=\"mailto:jon.crowcroft@cl.cam.ac.uk\">Jon Crowcroft</a> and me, and I spent\nmuch time back in 2015 working with him with <a href=\"https://www.highsecure.ru/httpcrypt.pdf\">HTTPCrypt</a>, a scheme for\nopportunistic HTTP encryption that uses NaCl-style cryptography to skip the full TLS\nhandshake by passing the server public key out-of-band (typically via DNS).\nThe paper got soundly <a href=\"https://www.usenix.org/conference/usenixsecurity15\">rejected from USENIX Security 2015</a>\nfor reasons I can't remember but weren't very important, but the\nprotocol lives on as the <a href=\"https://docs.rspamd.com/developers/encryption/\">encryption layer between rspamd and its clients</a>!</p>\n<p>Ok, so now by the time a message has survived postscreen and greylisting, well over 99%\nof the original bot traffic has been turned away at the door for a tiny\nfraction of the CPU cost of actually reading it. After this, we still need\nto do a bit of work scanning the content itself.</p>\n<h4 id=\"milter-clamav-and-bayesian-filtering\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#milter-clamav-and-bayesian-filtering\"></a>Milter, ClamAV and Bayesian filtering</h4>\n<p>Everything that makes it past postscreen and greylisting gets handed to rspamd\nover the <a href=\"https://www.postfix.org/MILTER_README.html\">milter</a> protocol.\nOur postfix is configured to consult rspamd for every message,\nwhich allows rspamd to either hard reject or defer a delivery while the\nsending server is still 'on the line'</p>\n<p>This allows dodgy mail to be refused at the source rather than accepted for delivery\nand then bounced afterwards (which is unreliable, as the bouncing address may also\nbe fake and cause <a href=\"https://en.wikipedia.org/wiki/Backscatter_(email)\">backscatter spam</a> to be generated by us!).</p>\n<p>The Postfix configuration for this milter is as easy as running the rspamd\ndaemon configured to listen on localhost.</p>\n<pre><code class=\"language-ini\"># /etc/postfix/main.cf\nsmtpd_milters = inet:localhost:11332\n</code></pre>\n<p><a href=\"https://www.clamav.net/\"> <img alt=\"%rc\" src=\"https://anil.recoil.org/images/clamav-logo.webp\"/> </a>\nThe first port of call is to filter out messages with known virus attachments.\n<a href=\"https://www.clamav.net/\">ClamAV</a> is what we use for this, and it maintains\nits own <a href=\"https://docs.clamav.net/manual/Signatures.html\">virus signature</a> database.\nrspamd hands the message body to the local <code>clamd</code> daemon over a Unix\nsocket, and rejects outright on a virus hit so the message is never delivered.</p>\n<pre><code class=\"language-ini\"># /etc/rspamd/local.d/antivirus.conf\nclamav { type = \"clamav\"; servers = \"/run/clamav/clamd.ctl\"; action = \"reject\"; }\n</code></pre>\n<p>The other main filter (among many) is rspamd's \"<a href=\"https://docs.rspamd.com/configuration/statistic/\">Bayesian\nclassifier</a>\" that scores each\nincoming message against a dynamic corpus of known <a href=\"https://en.wikipedia.org/wiki/Anti-spam_techniques\">spam and\nham</a> messages stored in\n<a href=\"https://redis.io/\">Redis</a>. The classifier auto-learns from messages that score\nextremely high or low, but it can also be personalised by the Recoil users by\nkeeping an eye on each user's Junk folder and adding those to the classifier.</p>\n<p>These messages are learnt by being piped to the <code>rspamc</code> command, which can\nlearn both ham and spam on stdin. Over a few weeks of doing this on every\nfalse-positive (or false-negative), the classifier gets pretty good\nat matching what our users want to see without having to maintain a static database.</p>\n<pre><code class=\"language-bash\">$ rspamc stat\nResults for command: stat (0.028 seconds)\nMessages scanned: 6206\nMessages with action reject: 135, 2.18%\nMessages with action soft reject: 0, 0.00%\nMessages with action rewrite subject: 0, 0.00%\nMessages with action add header: 137, 2.21%\nMessages with action greylist: 658, 10.60%\nMessages with action no action: 5276, 85.01%\nMessages treated as spam: 272, 4.38%\nMessages treated as ham: 5934, 95.62%\n</code></pre>\n<h3 id=\"delivering-email-with-lmtp-and-sieve\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#delivering-email-with-lmtp-and-sieve\"></a>Delivering email with LMTP and Sieve</h3>\n<p>Once our intrepid email message has run the gauntlet of postscreen,\ngreylisting, rspamd, ClamAV and Bayesian classifiers, we <em>finally</em> get to\nactually send it to the right user.</p>\n<p>By default Postfix would just write the file straight into the user's home\ndirectory, but that's not much use in the modern world where the volume of\nemail most people receive means that we'd like to file them into folders.</p>\n<p>We therefore hand the message over to <a href=\"https://www.dovecot.org/\">Dovecot</a> via\n<a href=\"https://www.rfc-editor.org/rfc/rfc2033\">LMTP</a>, which is basically SMTP (the protocol\nwe used to receive the email from the outside world), but without the queueing complexity.\nThis handoff happens over a normal Unix domain socket that's inside Postfix's\nqueue directory:</p>\n<pre><code class=\"language-ini\"># /etc/postfix/main.cf\nvirtual_alias_domains = recoil.org\nvirtual_alias_maps = hash:/etc/postfix/recoil.org\nmailbox_transport = lmtp:unix:private/dovecot-lmtp\n</code></pre>\n<p><code>virtual_alias_maps</code> turns an arbitrary <code>anything@recoil.org</code> address into an\naddress that can be delivered locally (e.g. <code>anil@recoil.org</code> goes to <code>avsm@pork.recoil.org</code>).\nThe reason for handing off to Dovecot rather than letting Postfix write the maildir directly\nis that Dovecot then owns the local user operations of indexing, quotas, full-text search and Sieve\nfiltering. We'll come back to this in the <a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#accessing-email-for-users\">email retrieval</a> section.</p>\n<h4 id=\"durable-on-disk-storage-with-maildir\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#durable-on-disk-storage-with-maildir\"></a>Durable on-disk storage with Maildir</h4>\n<p>Our storage format on disk is the reliable old <a href=\"https://en.wikipedia.org/wiki/Maildir\">Maildir</a>\nformat, which stores each email as a single file under each user's <code>~/Maildir</code>. It's a format\nthat we've been using on Recoil since 1998, when we first used <a href=\"http://qmail.org/man/man5/maildir.html\">qmail</a> as our mail server.\nThe reason I like it so much is that <a href=\"https://github.com/avsm/maildir-eio\">processing libraries</a> are\ntrivial to write, so email is never locked up in a proprietary format or database over the march\nof decades.</p>\n<p>The format itself is minimal. Each <code>~/Maildir</code> is just three\nsubdirectories <code>tmp/</code>, <code>new/</code> and <code>cur/</code>.</p>\n<pre><code class=\"language-bash\">/home/avsm/Maildir/.archive.2018\n/home/avsm/Maildir/.archive.2018/tmp\n/home/avsm/Maildir/.archive.2018/cur\n/home/avsm/Maildir/.archive.2018/new\n/home/avsm/Maildir/.github.mention\n/home/avsm/Maildir/.github.mention/cur\n/home/avsm/Maildir/.github.mention/new\n/home/avsm/Maildir/.github.mention/tmp\n...\n</code></pre>\n<p>The concurrency story is much simpler as <a href=\"https://en.wikipedia.org/wiki/Rename_(computing)\">POSIX guarantees local atomicity</a>\nof file rename operations. An incoming message\nis first written to a unique file in <code>tmp/</code>, then once fully written is (atomically) renamed\ninto <code>new/</code>.</p>\n<p>A client reading the Maildir then moves files from <code>new/</code> into <code>cur/</code> once\nthey've been seen. The message file itself is immutable, and so clients use the\nfilename to store message information by appending a flag suffix (e.g. <code>:2,S</code>\nfor Seen, <code>:2,SR</code> for Seen and Replied). With this setup, no user level mailbox-wide locking is needed and so\nPostfix can deliver a new message without synchronisation while normal email\nreading is going on.</p>\n<h4 id=\"indexing-the-email-with-flatcurve\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#indexing-the-email-with-flatcurve\"></a>Indexing the email with Flatcurve</h4>\n<p>I did evaluate some much more modern email servers like <a href=\"https://stalw.art/\">Stalwart</a>\nwhich have tons of cool features, but ultimately decided against switching\nbecause they don't support Maildir. They instead require stashing email in a\ncustom database format (e.g. in RocksDB) which (I think) mixes up the durability\nof email with having fast search.\nInstead, I took advantage of Dovecot support for full text indexing via a\n<em>separate</em> index, which is the\n<a href=\"https://github.com/slusarz/dovecot-fts-flatcurve\">Flatcurve</a> full-text index.</p>\n<p>Flatcurve is actually a wrapper around the venerable <a href=\"https://xapian.org/\">Xapian</a>,\nwhich (like rspamd) is yet another locally developed Cambridge technology!\n<a href=\"https://en.wikipedia.org/wiki/Martin_Porter\">Martin Porter</a> (inventor of the famous\n<a href=\"https://en.wikipedia.org/wiki/Stemming\">stemming algorithm</a>), did the Computer Science\nDiploma in the CL in 1967 and released the first version of Xapian in 2002. Note that\nthere's no connection to our <a href=\"https://anil.recoil.org/papers/2010-icfp-xen\">OCaml Xen XAPI toolstack</a> which was\ndeveloped by us independently in 2004!</p>\n<p>In our setup, Flatcurve keeps a separate Xapian index per mailbox under\n<code>~/Maildir/fts-flatcurve</code> and updates it automatically as new mail arrives.\nThe Dovecot side of the config is just:</p>\n<pre><code class=\"language-ini\"># /etc/dovecot/conf.d/90-fts-flatcurve.conf\nmail_plugins { fts = yes; fts_flatcurve = yes }\nfts_autoindex = yes\n</code></pre>\n<p>Flatcurve also ships CLI tools as part of the Dovecot plugin to mess around\nwith the index or do CLI searches (handy for agentic search!):</p>\n<pre><code class=\"language-bash\"> $ doveadm fts flatcurve stats -u avsm INBOX\nINBOX guid=436177290d20ee4e664a00007b9d9320 last_uid=1018743\nmessages=83485 shards=2 version=1\n</code></pre>\n<h4 id=\"the-sieve-language-for-server-side-filtering\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#the-sieve-language-for-server-side-filtering\"></a>The Sieve language for server-side filtering</h4>\n<p>Before the message lands in the maildir, we also need to decide which folder\nto put it into, or do other preprocessing like labeling. Since this can get\narbitrarily complicated based on the user's needs, we use a\n<a href=\"https://pigeonhole.dovecot.org/\">Pigeonhole Sieve</a> plugin that runs user-defined\ndelivery-time filters.</p>\n<p>Sieve (<a href=\"https://www.rfc-editor.org/rfc/rfc5228\">RFC 5228</a>) is a declarative language designed specifically for mail\nfiltering. There's a system-wide script that runs first and\nfiles anything rspamd has flagged into <code>Junk</code>, and then each user's personal\nscript runs:</p>\n<pre><code class=\"language-ini\"># /etc/dovecot/conf.d/90-sieve.conf\nsieve_script spam-to-junk { type = before; path = /etc/dovecot/sieve/spam-to-junk.sieve }\nsieve_script personal { path = ~/sieve; active_path = ~/.dovecot.sieve }\n</code></pre>\n<pre><code class=\"language-sieve\"># /etc/dovecot/sieve/spam-to-junk.sieve\nrequire [\"fileinto\", \"mailbox\"];\nif header :contains \"X-Spam\" \"Yes\" {\n fileinto :create \"Junk\";\n stop;\n}\n</code></pre>\n<p>Running these rules at delivery time on the server\nmeans the same rules apply whether I'm eventually reading mail on my laptop, phone, or\nvia webmail. I can also write Sieve rules for very custom vacation rules, email\npriorities, and coding agents like Claude can easily figure out the DSL intricacies.\nI found the <a href=\"https://gist.github.com/Hotrod369/6b7a24e1ea060e48e0c02459cbb950a0\">Sieve cheatsheet</a> very\nuseful here: you can do things like <a href=\"https://gist.github.com/Hotrod369/6b7a24e1ea060e48e0c02459cbb950a0#complex-sieve-script-examples\">modify messages</a>, create dynamic folders and <a href=\"https://doc.dovecot.org/2.3/configuration_manual/sieve/extensions/editheader/\">edit headers</a>.</p>\n<p>There's also a \"ManageSieve\" (<a href=\"https://www.rfc-editor.org/rfc/rfc5804\">RFC 5804</a>) daemon running,\nwhich lets a mail client edit a user's Sieve script remotely without needing shell access.\nI got this working with both <a href=\"https://addons.thunderbird.net/en-US/thunderbird/addon/sieve/\">Thunderbird</a>\nand <a href=\"https://plugins.roundcube.net/packages/kolab/managesieve\">Roundcube</a> which bundles a plugin natively.</p>\n<p>My email filter's massive, but I generate it from OCaml code that outputs something like:</p>\n<pre><code class=\"language-sieve\">if header :contains \"List-Id\" \"caml-list.inria.fr\"\n{\n fileinto \"dev.caml-list\";\n stop;\n}\nif header :contains \"List-Id\" \"types-list.LISTS.SEAS.UPENN.EDU\"\n{\n fileinto \"lists.types\";\n stop;\n}\n&lt;...&gt;\n</code></pre>\n<p>The Junk Bayesian training loop from earlier piggybacks on this too, as a Sieve\nIMAP event\n(<a href=\"https://www.rfc-editor.org/rfc/rfc6785\">RFC 6785</a>) script fires on each\nfolder move, which then pipes the message to <code>rspamc learn_spam</code> or <code>learn_ham</code>.\nThis might all feel like a Rube Goldberg machine, but each component does have\nits own specialised role.</p>\n<h2 id=\"sending-email-to-other-people\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#sending-email-to-other-people\"></a>Sending email to other people</h2>\n<p>We've so far put a stupid amount of effort into <em>receiving</em> email safely, but\nthis wouldn't be much use if we also can't reliably <em>send</em> email that won't\nget rejected.\nFailing any one of the gauntlet of checks by the hyperscalers will send our mail to\nsomeone's spam folder or be quietly dropped. There are three separate protocols\nthat work together to avoid this unfortunate outcome: SPF, DKIM and DMARC.</p>\n<h3 id=\"spf-describes-who-can-send-email-for-a-domain\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#spf-describes-who-can-send-email-for-a-domain\"></a>SPF describes who can send email for a domain</h3>\n<p>The <a href=\"https://www.rfc-editor.org/rfc/rfc7208\">SPF</a> (Sender Policy Framework) protocol\nis a DNS TXT record at the apex of our email domain which declares which IP addresses are\nallowed to <em>originate</em> mail claiming to be from <code>@recoil.org</code>. As before, we can\nquery the live record for Recoil from the CLI:</p>\n<pre><code class=\"language-bash\">$ dig +short TXT recoil.org\n\"v=spf1 a mx -all\"\n\"Llamaz United\"\n$ dig mx recoil.org +short\n10 pork.recoil.org.\n</code></pre>\n<p>The first one is the SPF record, and the second is a random record I created in\n1999 to test TXT records. The SPF entry says that the only valid senders for\n<code>recoil.org</code> are whatever hosts the <code>MX</code> records of <code>recoil.org</code> point to. In\nour case this is just <code>pork.recoil.org</code>, and everything else is expected\nto be illegitimate email not authorized by us.</p>\n<p>It's possible to also declare a softer <code>~all</code> softfail that lets receivers accept dubious mail with a\nwarning. This setup is safe for Recoil because we never send legitimate mail from anywhere except our\nown mail server.</p>\n<p>One little footgun is that we have lots of IP addresses bound to the\npork.recoil.org host (because of the aforementioned /24 IPv4 block that we were\nallocated), and so the Postfix daemon needs to be bound specifically to one\naddress to ensure that all of the outbound TCP connections it makes are indeed\nfrom the MX entry and not another address from our pool.</p>\n<pre><code class=\"language-ini\"># /etc/postfix/main.cf\nsmtp_bind_address = 185.33.27.128\nsmtp_bind_address6 = 2a00:1098:39c::3\n</code></pre>\n<p>Without these, Postfix would happily send out from whatever address the kernel\ndecides to use, which might be the IPv4 we use for the webmail or some other\nservice entirely, and the receiver's SPF check would fail...</p>\n<h3 id=\"dkim-cryptographic-signing-of-outbound-mail\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#dkim-cryptographic-signing-of-outbound-mail\"></a>DKIM: cryptographic signing of outbound mail</h3>\n<p>SPF only authenticates the connecting IP of the email server to other people, but\nsays nothing about the contents of the message itself. We could still use a way\nto authenticate that a full message has been certified by Recoil as originating\nfrom us, even if that message has been through several other email relays (e.g. by\nbeing forwarded).</p>\n<p>The <a href=\"https://www.rfc-editor.org/rfc/rfc6376\">DKIM</a> (DomainKeys Identified Mail)\nprotocol adds a per-message cryptographic signature, in a <code>DKIM-Signature:</code>\nemail header. This covers a canonicalised form of the body and selected headers,\nto permit some flexibility in rearranging the email but still be robust against\ntampering. The public verification keys for DKIM live in the public DNS and so\nare available for any receiver to easily check.</p>\n<p>The job of signing every single message leaving Recoil is <a href=\"https://docs.rspamd.com/modules/dkim_signing/\">rspamd's job</a>. Our\nDKIM private key lives on disk and never leaves the server. rspamd\nadds the signature to every outbound message during the same milter pass that\n<a href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#milter-clamav-and-bayesian-filtering\">scores inbound mail</a>:</p>\n<pre><code># /etc/rspamd/local.d/dkim_signing.conf\ndomain {\n recoil.org { selector = \"mail\"; path = \"/var/lib/rspamd/dkim/recoil.org.mail.key\" }\n}\n</code></pre>\n<p>Since you don't want to have the same private key in use forever, DKIM\nsupports rotation via \"selectors\" (<code>mail</code> in our case). This lets us rotate keys by\npublishing a new public key under a new selector while keeping the old one\nlive, so signatures on already-sent mail still verify. The public side\nlives in DNS at <code>&lt;selector&gt;._domainkey.&lt;domain&gt;</code>:</p>\n<pre><code>$ dig +short TXT mail._domainkey.recoil.org\n\"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\"\n</code></pre>\n<p>One gotcha if you're setting this up yourself is that DKIM TXT records\nfrequently exceed the <a href=\"https://www.rfc-editor.org/rfc/rfc7208\">255-byte string\nlimit</a> and have to be split into\nmultiple quoted strings inside one record. Most authoritative DNS providers\nwill do that for you, but you may need to mess around in their various web UIs to figure out how.</p>\n<p>Another oddity about DKIM is that it's really intended for live verification. If you want\nto go back and re-verify some emails that are (say) a few years old, then the DNS keys\nwill have expired and so you won't be able to do so. If anyone knows of any DKIM\n<a href=\"https://certificate.transparency.dev/\">transparency logs</a> like exist for TLS, I'd love\nto try it to go back over my historical email and do some data mining.</p>\n<h3 id=\"dmarc-ties-it-together-and-provides-reporting-stats\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#dmarc-ties-it-together-and-provides-reporting-stats\"></a>DMARC ties it together and provides reporting stats</h3>\n<p>Neither SPF nor DKIM actually check the <code>From:</code> header that\ndecides what our users actually see in their mail client:</p>\n<ul>\n<li>SPF only authenticates the envelope sender (the <code>MAIL FROM</code> SMTP command)</li>\n<li>DKIM only authenticates the sending domain in its own <code>d=</code> tag.</li>\n</ul>\n<p>Without some glue, a spammer could pass the SPF check for their own <code>evil.example.com</code>,\nthen sign a message with a valid DKIM key for <code>evil.example.com</code>, and\n<em>still</em> write <code>From: anil@recoil.org</code> in the message that the user eventually reads.\nThe protocol glue to prevent this is <a href=\"https://www.rfc-editor.org/rfc/rfc7489\">DMARC</a>, which checks\nthat the domains authenticated by SPF and DKIM actually match the visible\n<code>From:</code> in the email message, and also tells receivers what to do when the check fails.</p>\n<p>As you might have guessed by now, this involved yet another DNS record:</p>\n<pre><code>$ dig +short TXT _dmarc.recoil.org\n\"v=DMARC1; p=quarantine; rua=mailto:postmaster@pork.recoil.org\"\n</code></pre>\n<p>The strongest policy is <code>p=reject</code>, but we're going for a softer 'quarantine'\nuntil I'm comfortable with the setup for a few more months.\nA <em>really</em> useful part for actually debugging deliverability (given how many\nthird parties are involved here) is <code>rua=</code>, which is the email address for\na regular aggregate report.</p>\n<p>Once a day or so, every major receiver who gets email from Recoil (including Google,\nMicrosoft, Yahoo, Fastmail, and some smaller ones) sends an XML report to\nthis address summarising the messages they saw claiming to be from\n<code>recoil.org</code>.\nSome of these actually look like valid fails; for example this one from Yahoo\nseems to indicate that we've had some email sent <em>not</em> from our servers:</p>\n<pre><code class=\"language-xml\">$ gzcat yahoo.co.uk!recoil.org!1780617600!1780703999.xml.gz\n&lt;?xml version=\"1.0\"?&gt;\n&lt;feedback&gt;\n &lt;report_metadata&gt;\n &lt;org_name&gt;Yahoo&lt;/org_name&gt;\n &lt;email&gt;dmarchelp@yahooinc.com&lt;/email&gt;\n &lt;report_id&gt;1780732274.742817&lt;/report_id&gt;\n &lt;date_range&gt;\n &lt;begin&gt;1780617600&lt;/begin&gt;\n &lt;end&gt;1780703999&lt;/end&gt;\n &lt;/date_range&gt;\n &lt;/report_metadata&gt;\n &lt;policy_published&gt;\n &lt;domain&gt;recoil.org&lt;/domain&gt;\n &lt;adkim&gt;r&lt;/adkim&gt;\n &lt;aspf&gt;r&lt;/aspf&gt;\n &lt;p&gt;quarantine&lt;/p&gt;\n &lt;pct&gt;100&lt;/pct&gt;\n &lt;/policy_published&gt;\n &lt;record&gt;\n &lt;row&gt;\n &lt;source_ip&gt;192.134.164.83&lt;/source_ip&gt;\n &lt;count&gt;4&lt;/count&gt;\n &lt;policy_evaluated&gt;\n &lt;disposition&gt;quarantine&lt;/disposition&gt;\n &lt;dkim&gt;fail&lt;/dkim&gt;\n &lt;spf&gt;fail&lt;/spf&gt;\n &lt;/policy_evaluated&gt;\n &lt;/row&gt;\n &lt;identifiers&gt;\n &lt;header_from&gt;recoil.org&lt;/header_from&gt;\n &lt;/identifiers&gt;\n &lt;auth_results&gt;\n &lt;dkim&gt;\n &lt;domain&gt;inria.fr&lt;/domain&gt;\n &lt;selector&gt;dc&lt;/selector&gt;\n &lt;result&gt;pass&lt;/result&gt;\n &lt;/dkim&gt;\n &lt;spf&gt;\n &lt;domain&gt;inria.fr&lt;/domain&gt;\n &lt;result&gt;pass&lt;/result&gt;\n &lt;/spf&gt;\n &lt;/auth_results&gt;\n &lt;/record&gt;\n&lt;/feedback&gt;\n</code></pre>\n<p>A little bit of sleuthing on that IP shows that:</p>\n<pre><code>$ host 192.134.164.83\n83.164.134.192.in-addr.arpa domain name pointer mail2-relais-roc.national.inria.fr\n</code></pre>\n<p>...it's the INRIA email server, which probably means that I sent an\nemail to 'caml-devel@inria.fr' from 'anil@recoil.org', which proceeded to forward\nthat to a recipient hosted on a '@yahoo.com' email address which failed\nverification since it hadn't come straight from recoil. Note that both\nSPF failed (to be expected since the INRIA server would have sent the email)\nbut <em>also</em> DKIM failed (since the INRIA server probably rewrote some mail headers).</p>\n<p>This is all quite complex sounding (and it is), but it is invaluable to help\ndebug the distributed system over time.\nI run a quick OCaml script over the various emails that are coming in, and steadily\ntelling our users when I need to reconfigure one of their clients.\nDMARC reporting itself has had some security implications. A <a href=\"https://www.usenix.org/conference/usenixsecurity23/presentation/ashiq\">2023 study</a> demonstrated that a single attacker email can be\nturned into a flood of DMARC reports. The fix was to lock down the acceptable\n<code>rua</code> addresses to the domain itself, so it doesn't apply to our self hosting setup.</p>\n<h3 id=\"srs-to-keep-email-forwarding-working\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#srs-to-keep-email-forwarding-working\"></a>SRS to keep email forwarding working</h3>\n<p>There's one painful corner case that I identified above that I haven't quite sorted\nyet: mailing lists. This is why we're still in 'quarantine' mode for our Recoil setup.\nIf someone emails <code>anil@recoil.org</code> and my server forwards it on to (say) my\nCambridge address, the original sender's domain is now being sent to the\ndestination from our IP, which will fail their SPF check.</p>\n<p>The fix is the <a href=\"https://www.open-spf.org/SRS/\">Sender Rewriting Scheme</a> (SRS),\nimplemented by <a href=\"https://github.com/roehling/postsrsd\">postsrsd</a>. Using this, we rewrite\nthe envelope sender on the way out from <code>original@example.com</code> to\nsomething like <code>SRS0=…=example.com=original@recoil.org</code>, so that SPF checks at the\ndestination evaluates against our domain. We also reverse the rewrite on the way back for any bounces.</p>\n<p>SRS doesn't seem to have an IETF RFC that I can find, but it does let some\nforwarding paths survive in this DMARC-enforced world. I'm still figuring out exactly how it all\nworks in our especially complex Cambridge email setup (which involves many hoops and\nforwarding layers), but this is all it takes in the Postfix setup for now:</p>\n<pre><code class=\"language-ini\"># /etc/postfix/main.cf\nsender_canonical_maps = socketmap:unix:srs:forward\nrecipient_canonical_maps = socketmap:unix:srs:reverse\n</code></pre>\n<p>Phew, so with SPF, DKIM, DMARC and SRS wired up, our deliverability index against\nGmail and Outlook seems reliable. Not one of our (loudly complaining) families has\ncomplained about spam since we switched to this setup. Hurrah!</p>\n<h2 id=\"accessing-email-for-users\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#accessing-email-for-users\"></a>Accessing email for users</h2>\n<p>Now that we can both send and receive email, all that's left is for users to be\nable to access it easily! On Recoil it comes down to two paths: a regular IMAP\nclient (e.g. Mail.app on macOS) talking to our Dovecot server, or via a\nweb browser pointing at our self-hosted webmail (which itself acts as an IMAP\nclient).</p>\n<h3 id=\"dovecot-and-imap\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#dovecot-and-imap\"></a>Dovecot and IMAP</h3>\n<p><a href=\"https://www.dovecot.org/\">Dovecot</a> handles all the mailbox access on <code>pork</code>,\nencrypting listeners with TLS (<a href=\"https://www.rfc-editor.org/rfc/rfc8314\">RFC 8314</a>).\nAll our ports require TLS so no plaintext mail or passwords ever cross the\npublic network. We use <a href=\"https://letsencrypt.org/\">LetsEncrypt</a> for this, with\nmultiple host aliases (like <code>imap.recoil.org</code> or <code>smtp.recoil.org</code>) served via\n<a href=\"https://en.wikipedia.org/wiki/Server_Name_Indication\">SNI</a> so that our users\nwho last configured their phones in 2008 don't have to touch anything:</p>\n<pre><code class=\"language-ini\"># /etc/dovecot/conf.d/11-ssl-imap.conf\nlocal_name imap.recoil.org {\n ssl_server_cert_file = /etc/letsencrypt/live/imap.recoil.org/fullchain.pem\n ssl_server_key_file = /etc/letsencrypt/live/imap.recoil.org/privkey.pem\n}\n</code></pre>\n<p>Dovecot also pulls double duty as Postfix's SASL backend (<a href=\"https://www.rfc-editor.org/rfc/rfc4422\">RFC\n4422</a>) for outbound submission. This\nallows users to have the same password for IMAP (to access their email) and\nSMTP (to send email).</p>\n<h3 id=\"roundcube-webmail\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#roundcube-webmail\"></a>Roundcube webmail</h3>\n<p>I used to work on <a href=\"https://anil.recoil.org/notes/horde-developer\">Horde IMP back in the 2000s</a>, and so\nI did try to get my beloved <a href=\"https://github.com/horde/imp/\">IMP webmail</a> running again. However, it looks\nlike it's between release cycles right now and things are in flux,\nso I switched over to running\n<a href=\"https://roundcube.net/\">Roundcube</a> behind a\n<a href=\"https://caddyserver.com/\">Caddy</a> TLS reverse proxy, all packaged together\nas a Docker Compose service.</p>\n<p>Roundcube is configured to connect to\n<code>pork</code> over the same TLS/IMAP as any other client would:</p>\n<pre><code class=\"language-yaml\">services:\n roundcube:\n image: roundcube/roundcubemail\n environment:\n ROUNDCUBEMAIL_DEFAULT_HOST: ssl://pork.recoil.org\n ROUNDCUBEMAIL_SMTP_SERVER: tls://pork.recoil.org\n ROUNDCUBEMAIL_PLUGINS: \"managesieve,markasjunk,archive\"\n caddy:\n image: caddy:latest\n</code></pre>\n<p>The Roundcube plugins I'm using are:</p>\n<ul>\n<li><a href=\"https://plugins.roundcube.net/packages/kolab/managesieve\"><code>managesieve</code></a>\nthat speaks the <a href=\"https://www.rfc-editor.org/rfc/rfc5804\">ManageSieve</a> protocol\nto allow editing a Sieve filter in the browser.</li>\n<li><a href=\"https://plugins.roundcube.net/packages/johndoh/markasjunk\"><code>markasjunk</code></a> translates the \"Junk button\" in the webmail into a move to the Junk folder that causes the ham/spam classification to function invisibly to the user.</li>\n</ul>\n<p><img alt=\"%c\" src=\"https://anil.recoil.org/images/roundcube-ss-filter.webp\" title=\"The Roundcube ManageSieve UI doesn't expose the raw Sieve DSL, so it's easier to use\"/></p>\n<h2 id=\"what-else-is-left-to-do\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#what-else-is-left-to-do\"></a>What else is left to do?</h2>\n<p>This setup has been pretty solid for day-to-day use in the past few weeks, but\nthere is (always) more work to do.</p>\n<p>As a recap, here's the list of DNS records <code>recoil.org</code> publishes\nto make everything work:</p>\n<div role=\"region\"><table>\n<tr>\n<th>Record</th>\n<th>Purpose</th>\n<th>Reference</th>\n</tr>\n<tr>\n<td><code>MX (pork.recoil.org)</code></td>\n<td>Where mail for the domain is delivered</td>\n<td><a href=\"https://www.rfc-editor.org/rfc/rfc5321\">RFC 5321 §5</a></td>\n</tr>\n<tr>\n<td><code>A</code> / <code>AAAA</code></td>\n<td>pork's IP addresses</td>\n<td><a href=\"https://www.rfc-editor.org/rfc/rfc1035\">RFC 1035</a></td>\n</tr>\n<tr>\n<td><code>PTR</code> (rDNS)</td>\n<td>IP to <code>pork.recoil.org</code> reverse mapping</td>\n<td><a href=\"https://www.rfc-editor.org/rfc/rfc1912\">RFC 1912 §2.1</a></td>\n</tr>\n<tr>\n<td><code>TXT</code> SPF (<code>v=spf1 mx -all</code>)</td>\n<td>Which hosts may send for the domain</td>\n<td><a href=\"https://www.rfc-editor.org/rfc/rfc7208\">RFC 7208</a></td>\n</tr>\n<tr>\n<td><code>TXT</code> DKIM (<code>mail._domainkey</code>)</td>\n<td>Public key for signature verification</td>\n<td><a href=\"https://www.rfc-editor.org/rfc/rfc6376\">RFC 6376</a></td>\n</tr>\n<tr>\n<td><code>TXT</code> DMARC (<code>_dmarc</code>)</td>\n<td>Policy and reporting for SPF/DKIM alignment</td>\n<td><a href=\"https://www.rfc-editor.org/rfc/rfc7489\">RFC 7489</a></td>\n</tr>\n</table></div><h3 id=\"modern-transport-security-mta-sts-dane-and-dnssec\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#modern-transport-security-mta-sts-dane-and-dnssec\"></a>Modern transport security: MTA-STS, DANE and DNSSEC</h3>\n<p><a href=\"https://www.rfc-editor.org/rfc/rfc8461\">MTA-STS</a> tells other\nmail servers they should only talk to us over TLS with a valid\ncertificate. This mitigates the <a href=\"https://nostarttls.secvuln.info/\">STARTTLS-downgrade attack</a>\nwhereby an attacker strips the TLS upgrade from the SMTP session. It also\nhelps that email between servers is guaranteed to be TLS encrypted so that\ncasual network snooping can no longer read emails.</p>\n<p><a href=\"https://www.rfc-editor.org/rfc/rfc7672\">DANE/TLSA</a> adds support for\nDNS-pinned TLS certificate hashes, rather than using HTTPS for this. The\ndelay in deploying this is that DANE requires the DNS zone to be DNSSEC-signed, which <code>recoil.org</code>\nisn't yet. Moving a domain to DNSSEC requires understanding a lot more about\nkey rotation than I have time for right now, but it's getting higher up on my\nTODO list!</p>\n<p><a href=\"https://www.open-spf.org/SRS/\">SRS</a> is semi-deployed right now,\nbut I haven't tested it against every forwarding path that\nexists in our setup. In particular, the INRIA failure is a bit worrying as\nit triggers a DMARC failure (and hence might affect our domain reputation), but\ninvolves an email server out of my immediate control.</p>\n<h3 id=\"a-jmap-proxy-in-ocaml\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#a-jmap-proxy-in-ocaml\"></a>A JMAP proxy in OCaml?</h3>\n<p>I'd also like to expose email access via\n<a href=\"https://www.rfc-editor.org/rfc/rfc8620\">JMAP</a> (the JSON Mail Access\nProtocol, <a href=\"https://www.rfc-editor.org/rfc/rfc8620\">RFC 8620</a>\nand <a href=\"https://www.rfc-editor.org/rfc/rfc8621\">RFC 8621</a>)\nJMAP is a much nicer fit for modern network clients than IMAP is, as\nit uses more widely deployed protocols and formats like HTTPS and JSON.</p>\n<p>However, Dovecot doesn't speak JMAP natively, and the only standalone JMAP servers I've\nevaluated (like Stalwart) all want to own the mailbox storage themselves, which would mean giving up Maildir.\nI'm not quite willing to give up the simplicity of that email storage just yet...</p>\n<p>The plan I'm considering is to put my <a href=\"https://anil.recoil.org/notes/aoah-2025-17\">OCaml JMAP implementation</a> in front of Dovecot as a translating proxy.\nJMAP requests would come in over HTTPS, get mapped to IMAP calls, and the responses can be sent back as JSON.\nThis also gives me an excuse to stress-test my OCaml JMAP code against real traffic. Stay tuned!</p>\n<h2 id=\"is-this-a-negative-result-for-self-hosting\"><a aria-hidden=\"true\" class=\"anchor\" href=\"https://anil.recoil.org/notes/recoil-self-hosting-2026/#is-this-a-negative-result-for-self-hosting\"></a>Is this a negative result for self-hosting?</h2>\n<p>It is rather unfortunate that \"running an email server\" in 2026 means\ngetting at least six separate DNS records correct before reliably sending or\nreceiving email. And securing an IPv4 block allocation from RIPE took <a href=\"https://github.com/samoht\">Thomas Gazagnaire</a> almost a year.\nAnd keeping all this up-to-date is a fair bit of work with respect to security,\nbut both <a href=\"https://nick.recoil.org\">Nick Ludlam</a> and I use this (along with our friends and family who have accounts)\nso it's for a small group of people.</p>\n<p>The upside though, is what an excellent learning process it is to go through to\nget up to speed on how the modern Internet really works. Email these days can\nreset almost any aspect of our digital lives, and so it feels important to\nmaintain some semblance of agency over how it works. And it is quite\nheartwarming that it's still possible to do on the Internet as a small outfit\nwithout requiring any central authority to approve it!</p>\n<p>The other thing I'm increasingly conscious of is that \"secure\" is a moving\ntarget. Self-hosted services like ours have always faced opportunistic bot\nscans, but the <a href=\"https://anil.recoil.org/notes/internet-immune-system\">autonomous chaining of vulnerabilities by frontier AI models</a> has completely shifted the threat model.</p>\n<p>The gap between a CVE being published and a working exploit being thrown at every\nSMTP/IMAP listener on the public Internet is now probably measured in hours and not\nweeks. Most of the hardening choices in this post; pinning Postfix to\nspecific addresses, isolating the webmail in containers on a separate IP,\ngreylisting and DNSBLs before handling email, are all pretty conventional\ndecisions to get some security in depth.\nIt does make me want to push way harder towards the dynamic antibotty-style active defences\nI <a href=\"https://anil.recoil.org/papers/2025-internet-ecology\">sketched out last year</a>...</p>\n<p><img alt=\"%c\" src=\"https://anil.recoil.org/images/moving-recoil-9.webp\" title=\"Running your email server will occasionally result in your zooming through central London in a white van desperately trying to stop it flying out of a window (me, Nick Ludlam, James Cronin, 2002)\"/></p>\n<p>I hope that this guide might come in useful to anyone else who wants to have a go! I'm particularly excited by projects like <a href=\"https://ryan.freumh.org/talks/2026-fosdem-eilean.html\">Eilean</a> which make this process more of a single-click process to deploy. Over the course of the next few months, I plan to write about how we're self hosting <em>other</em> services like photos, chat, location and more; see also our <a href=\"https://anil.recoil.org/notes/mirage-self-hosting\">self-hosted MirageOS website</a> or my <a href=\"https://anil.recoil.org/notes/owntracks-and-lifecycle\">OwnTracks location stack</a> for some background. It's good fun!</p><h1>References</h1><ul><li>Madhavapeddy et al (2025). Steps towards an Ecology for the Internet. Association for Computing Machinery. <a href=\"https://doi.org/10.1145/3744169.3744180\" target=\"_blank\"><i>10.1145/3744169.3744180</i></a></li>\n<li>Scott et al (2010). Using functional programming within an industrial product group: perspectives and perceptions. ACM. <a href=\"https://doi.org/10.1145/1863543.1863557\" target=\"_blank\"><i>10.1145/1863543.1863557</i></a></li>\n<li>Madhavapeddy (2025). Tracking locations with OwnTracks, Life Cycle and Home Assistant. <a href=\"https://doi.org/10.59350/13ras-yd957\" target=\"_blank\"><i>10.59350/13ras-yd957</i></a></li>\n<li>Madhavapeddy (2026). Rewilding the Web: my workshop report from Edinburgh. <a href=\"https://doi.org/10.59350/g40yy-ks003\" target=\"_blank\"><i>10.59350/g40yy-ks003</i></a></li>\n<li>Madhavapeddy (2026). The Internet needs an antibotty immune system, stat. <a href=\"https://doi.org/10.59350/snnnf-asc02\" target=\"_blank\"><i>10.59350/snnnf-asc02</i></a></li>\n<li>Madhavapeddy (2025). Jane Street and Docker on moving to OCaml 5 at ICFP/SPLASH 2025. <a href=\"https://doi.org/10.59350/3jkaq-d3398\" target=\"_blank\"><i>10.59350/3jkaq-d3398</i></a></li>\n<li>Doan et al (2022). An Empirical View on Consolidation of the Web. <a href=\"https://doi.org/10.1145/3503158\" target=\"_blank\"><i>10.1145/3503158</i></a></li>\n<li>Ramanathan et al (2020). Quantifying the Impact of Blocklisting in the Age of Address Reuse. ACM. <a href=\"https://doi.org/10.1145/3419394.3423657\" target=\"_blank\"><i>10.1145/3419394.3423657</i></a></li>\n<li>Ziv et al (2021). ASdb: a system for classifying owners of autonomous systems. ACM. <a href=\"https://doi.org/10.1145/3487552.3487853\" target=\"_blank\"><i>10.1145/3487552.3487853</i></a></li>\n<li>Olea et al (2025). Evaluating Phishing Email Efficacy. ACM. <a href=\"https://doi.org/10.1145/3716489.3728437\" target=\"_blank\"><i>10.1145/3716489.3728437</i></a></li></ul>",
143
+ "schema_version": "https://commonmeta.org/commonmeta_v1.0rc2.json"
138
144
  }
@@ -2,6 +2,10 @@
2
2
  "id": "https://doi.org/10.25491/d50j-3083",
3
3
  "type": "Dataset",
4
4
  "additional_type": "Gene expression matrices",
5
+ "container": {
6
+ "type": "DataRepository",
7
+ "title": "GTEx"
8
+ },
5
9
  "contributors": [
6
10
  {
7
11
  "type": "Organization",
@@ -13,12 +17,35 @@
13
17
  ]
14
18
  }
15
19
  ],
20
+ "title": "Fully processed, filtered and normalized gene expression matrices (in BED format) for each tissue, which were used as input into FastQTL for eQTL discovery",
16
21
  "date_published": "2017",
22
+ "funding_references": [
23
+ {
24
+ "funder_name": "Common Fund of the Office of the Director of the NIH"
25
+ },
26
+ {
27
+ "funder_name": "National Cancer Institute (NCI)"
28
+ },
29
+ {
30
+ "funder_name": "National Human Genome Research Institute (NHGRI)"
31
+ },
32
+ {
33
+ "funder_name": "National Heart, Lung, and Blood Institute (NHLBI)"
34
+ },
35
+ {
36
+ "funder_name": "National Institute on Drug Abuse (NIDA)"
37
+ },
38
+ {
39
+ "funder_name": "National Institute of Mental Health (NIMH)"
40
+ },
41
+ {
42
+ "funder_name": "National Institute of Neurological Disorders and Stroke (NINDS)"
43
+ }
44
+ ],
17
45
  "provider": "DataCite",
18
46
  "publisher": {
19
47
  "name": "GTEx"
20
48
  },
21
- "schema_version": "https://commonmeta.org/commonmeta_v1.0rc2.json",
22
49
  "subjects": [
23
50
  {
24
51
  "subject": "gtex"
@@ -36,7 +63,7 @@
36
63
  "subject": "transcriptomics"
37
64
  }
38
65
  ],
39
- "title": "Fully processed, filtered and normalized gene expression matrices (in BED format) for each tissue, which were used as input into FastQTL for eQTL discovery",
40
66
  "url": "https://ors.datacite.org/doi:/10.25491/d50j-3083",
41
- "version": "v7"
67
+ "version": "v7",
68
+ "schema_version": "https://commonmeta.org/commonmeta_v1.0rc2.json"
42
69
  }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "commonmeta-schema"
7
- version = "1.0rc2.dev4"
7
+ version = "1.0rc2.dev6"
8
8
  description = "Commonmeta JSON Schemas and conformance fixtures"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"