{"id":382,"date":"2026-07-06T21:13:45","date_gmt":"2026-07-06T21:13:45","guid":{"rendered":"https:\/\/marcinzygmunt.pl\/blog\/?p=382"},"modified":"2026-07-06T21:19:12","modified_gmt":"2026-07-06T21:19:12","slug":"mailmock-0-2-an-smtp-catcher-that-also-tells-you-if-the-mail-looks-dodgy","status":"publish","type":"post","link":"https:\/\/marcinzygmunt.pl\/blog\/mailmock-0-2-an-smtp-catcher-that-also-tells-you-if-the-mail-looks-dodgy\/","title":{"rendered":"mailmock 0.2 is out \u2014 an SMTP catcher that spots dodgy mail"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">I built <strong>mailmock<\/strong> several years ago to solve a small, recurring dev annoyance: I want to <strong>see<\/strong> the email my app sends without wiring up a real mail server \u2014 and I want it to be obvious when something&#8217;s off. It captures SMTP locally and shows every message in the browser, with an analysis layer on top.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Recently I added a few more security-oriented features and gave it some polish \u2014 that&#8217;s what 0.2 is about.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Catch mail, deliver nothing<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Point your app&#8217;s SMTP client at mailmock (plain port, no TLS, no auth) and every message is captured, parsed and shown in the browser. Nothing is delivered anywhere. It&#8217;s a single-tenant tool meant to run in your own environment.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">The Analysis tab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">This is the part I actually wanted. Every captured message is analyzed asynchronously across three dimensions:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Spam<\/strong>\u00a0\u2014 a 0\u2013100 score with a level and the reasons that pushed it up (keywords, ALL-CAPS, too many links, header anomalies).<\/li>\n\n\n\n<li><strong>Spoofing<\/strong>\u00a0\u2014 PASS \/ SUSPICIOUS \/ FAIL from header heuristics:\u00a0<code>From<\/code>\u00a0vs\u00a0<code>Return-Path<\/code>\u00a0\/ envelope mismatch, display-name spoofing,\u00a0<code>Authentication-Results<\/code>\u00a0failures, punycode look-alike domains,\u00a0<code>From<\/code>\u00a0vs\u00a0<code>Reply-To<\/code>\u00a0divergence.<\/li>\n\n\n\n<li><strong>Virus<\/strong>\u00a0\u2014 CLEAN \/ SUSPICIOUS \/ INFECTED from attachment scanning: the standard EICAR test signature and risky extensions.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The built-in analyzers are heuristic and run in-process \u2014 no external services, so the tab is always populated. If you want the real thing, enable&nbsp;<strong>ClamAV<\/strong>&nbsp;(virus) and&nbsp;<strong>Rspamd<\/strong>&nbsp;(spam); when reachable, their verdict replaces the built-in one, and virus only escalates (a risky attachment stays flagged even if ClamAV says clean).<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Also handy for security &amp; deliverability testing<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Because the spam score and the ClamAV\/EICAR checks run on whatever you send, mailmock doubles as a quick testing harness: confirm the mail your system generates won&#8217;t trip spam filters, that risky attachments actually get scanned (the EICAR signature, or real ClamAV), and that nothing looks spoofed \u2014 all before it reaches a real inbox.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">A readable HTML preview you can trust<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">The message view renders the original HTML in a&nbsp;<strong>sandboxed iframe<\/strong>: scripts disabled, remote resources blocked via CSP. Untrusted mail can&#8217;t run code in your browser or phone home through a tracking pixel. The preview sits on a white canvas like a real client, and it stays readable in the new, denser dark theme.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Correct parsing and storage<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Two robustness fixes worth calling out:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Nested multipart<\/strong>\u00a0now parses properly \u2014 the text body and every attachment are shown and downloadable no matter how deeply they&#8217;re nested (previously only the top level worked).<\/li>\n\n\n\n<li><strong>MongoDB mode<\/strong>\u00a0is buttoned up: search by recipient works, read\/unread is persisted, and the message-limit eviction drops the\u00a0<em>oldest<\/em>\u00a0message (it used to drop the newest).<\/li>\n<\/ul>\n\n\n\n<h4 class=\"wp-block-heading\">Try it<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>docker run -d --name mailmock \\\n  -p 8080:8080 \\\n  -p 1125:1125 \\\n  justmediapl\/mailmock:latest\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Point your SMTP client at&nbsp;<code>localhost:1125<\/code>, send some mail, open&nbsp;<code>http:\/\/localhost:8080<\/code>. In-memory storage and built-in analyzers \u2014 no external services required.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Live demo:\u00a0<a href=\"https:\/\/mailmock.app\/\">https:\/\/mailmock.app<\/a><\/li>\n\n\n\n<li>Docker image:\u00a0<code>justmediapl\/mailmock:latest<\/code><\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Stack: Java 21 \u00b7 Spring Boot 3.3 \u00b7 Thymeleaf \u00b7 WebSocket \u00b7 optional MongoDB \u00b7 optional ClamAV &amp; Rspamd \u00b7 Docker.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I built mailmock several years ago to solve a small, recurring dev annoyance: I want to see the email my app sends without wiring up a real mail\u2026<\/p>\n","protected":false},"author":1,"featured_media":384,"comment_status":"open","ping_status":"open","sticky":true,"template":"","format":"standard","meta":{"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[1],"tags":[116,111,113,117,115,15,118,112,114,5],"class_list":["post-382","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","tag-clamav","tag-devtools","tag-docker","tag-emailsecurity","tag-emailtesting","tag-java","tag-selfhosted","tag-smtp","tag-spamfiltering","tag-springboot"],"jetpack_likes_enabled":true,"jetpack_sharing_enabled":true,"jetpack_featured_media_url":"https:\/\/marcinzygmunt.pl\/blog\/wp-content\/uploads\/2026\/07\/fj4tsvzMZYBgIi4FBACpA_zICmL6Mp.jpg","_links":{"self":[{"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/posts\/382","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/comments?post=382"}],"version-history":[{"count":4,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/posts\/382\/revisions"}],"predecessor-version":[{"id":387,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/posts\/382\/revisions\/387"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/media\/384"}],"wp:attachment":[{"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/media?parent=382"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/categories?post=382"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/tags?post=382"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}