Skip to content

Commit b3cabc3

Browse files
Sieve gets :regex, wearing the same circuit breaker as the rules engine
The :regex match type (draft-ietf-sieve-regex) on header, address, envelope, body and hasflag, advertised as "regex" over ManageSieve. The draft never became an RFC, but Dovecot implements it and clients offer it, which is what a capability name is for. Semantics: a SEARCH - the pattern may hit anywhere in the value, and a script anchors with ^ and $ when it means the whole - where the rules engine's regex criterion is a whole-value match. Case folds under the default comparator (i;ascii-casemap) and does not under i;octet. i;ascii-numeric with :regex is refused, as for :contains and :matches. Two protections, because a script author is exactly as able to write a catastrophic pattern as a rule author, and this is the delivery thread: - Every key is compiled at upload. A pattern that cannot compile is refused with its line number, instead of being stored and silently never matching - the failure mode the legacy rules engine had for years (HM6042). - Evaluation runs under RuleGuard's budget-and-suspend breaker: 250 ms per evaluation, five-minute suspension keyed on the pattern text, reported once per window. SieveRegexMatches is a deliberately parallel sibling of RegexCriteriaMatches rather than a shared core - same suspension table, same budget, same error codes, each keeping its exact log wording, and the rules path character-for-character unchanged. The header says: if you change one, look at the other. The breaker test delivers a message whose crafted subject detonates (a+)+<unique>$ and asserts the thing that matters: the message ARRIVES, in INBOX, with the suspension reported - a hostile sender must never be able to turn a filter into mail loss. The pattern carries a per-run unique tail because suspension outlives a test run and reports only on insertion; a reused pattern would be answered from the table, report nothing, and fail the assertion for the wrong reason. Suite: 1513/1513.
1 parent 7c0ca10 commit b3cabc3

10 files changed

Lines changed: 339 additions & 11 deletions

File tree

Roadmap.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ strong and where it is thin far more honestly than any prose summary.
5757
| [Transport security and deliverability](#transport-security-and-deliverability) | 43 || 4 | 1 |
5858
| [IMAP](#imap) | 58 || 17 | 3 |
5959
| [POP3](#pop3) | 21 || 6 ||
60-
| [Sieve, ManageSieve and rules](#sieve-managesieve-and-rules) | 50 | 0 | 14 ||
60+
| [Sieve, ManageSieve and rules](#sieve-managesieve-and-rules) | 51 | 0 | 13 ||
6161
| [Authentication and cryptography](#authentication-and-cryptography) | 57 | 0 | 18 ||
6262
| [Anti-spam, anti-virus and content control](#anti-spam-anti-virus-and-content-control) | 54 || 13 ||
6363
| [Storage, accounts and data model](#storage-accounts-and-data-model) | 86 || 10 ||
@@ -72,7 +72,7 @@ strong and where it is thin far more honestly than any prose summary.
7272
| [Future-proofing: standards and protocols](#future-proofing-standards-and-protocols) | 2 || 4 | 2 |
7373
| [Future-proofing: platform and supply chain](#future-proofing-platform-and-supply-chain) | 5 | 1 | 2 | 2 |
7474
| [Future-proofing: deployment and operations](#future-proofing-deployment-and-operations) | 4 | 1 | 4 ||
75-
| **Total** | **625** | **12** | **143** | **15** |
75+
| **Total** | **626** | **12** | **142** | **15** |
7676

7777
Three things stand out and are worth naming rather than leaving to be inferred.
7878
**Storage and the administration surface are the best-covered areas**, and the
@@ -448,7 +448,7 @@ the source, not from documentation.
448448

449449
### Sieve, ManageSieve and rules
450450

451-
50 shipped · 0 underway · 14 not started · 0 deferred
451+
51 shipped · 0 underway · 13 not started · 0 deferred
452452

453453
| | Capability | Detail |
454454
|:-:|---|---|
@@ -506,7 +506,7 @@ the source, not from documentation.
506506
|| include (RFC 6609) | Not implemented, and refused rather than ignored: `include`, `return` and `global` are not in the known-command allowlist, so a script using them fails at upload with "unknown command". There is no personal/global script namespace in SieveStorage to include from. |
507507
| ✅ | mailbox / mboxmetadata (RFC 5490) | **The mailbox extension shipped 16 August 2026**: the `mailboxexists` test and `fileinto :create`. mailboxexists is the first test in this engine answered from outside the message - the delivery path hands the evaluator a callback over the recipient's real folder list, using the same lookup fileinto itself uses (`MessageUtilities::FolderExistsForDelivery`, kept beside `MoveToIMAPFolder` so the two cannot drift), and for a public folder the same insert-permission gate, because RFC 5490 3.1 defines "exists" as exists-and-deliverable. A caller with no store to ask (the COM test evaluator) gets false for every name - the safe answer. `:create` was already this server's behaviour - MoveToIMAPFolder has always created missing account folders - so the tag is accepted under its require and the semantics were proven rather than added: an end-to-end test SELECTs the folder the delivery created, over IMAP, from a real client session. Advertised over ManageSieve under the standing rule. Still absent from RFC 5490: the mboxmetadata half (METADATA is not implemented in the IMAP server at all). |
508508
|| Out-of-office scheduling and scope | Only an end date exists — there is no start date, so a future absence cannot be scheduled and must be switched on manually. There is also no domain-level or server-level auto-reply, no separate internal/external message… |
509-
|| regex (draft-ietf-sieve-regex) | Not implemented as a Sieve match type, even though the server already carries a regex engine used by the legacy rules engine (RuleCriteria::MatchesRegEx). Wiring it into MatchValue_ would be small |
509+
| ✅ | regex (draft-ietf-sieve-regex) | **Shipped 16 August 2026** as the `:regex` match type on header/address/envelope/body/hasflag, advertised as "regex" over ManageSieve. Never became an RFC, but Dovecot implements it and clients offer it. A search (anchor with `^`/`$` to mean the whole value), case-folded under the default comparator and case-sensitive under `i;octet`; `i;ascii-numeric` with `:regex` is refused. Two protections, both tested end to end: every key is COMPILED AT UPLOAD, so a pattern that cannot compile is refused with its line number instead of silently never matching (the failure mode the legacy rules engine had for years); and evaluation runs under the same RuleGuard budget-and-suspend breaker as the legacy regex criterion - a deliberately parallel sibling sharing the suspension table and error codes - so a catastrophic pattern costs one bounded evaluation per five-minute window, never the message. The breaker test delivers a crafted subject against `(a+)+…$` and asserts the message ARRIVES in INBOX with the suspension reported. |
510510
|| reject / ereject (RFC 5429) | Not implemented. **The hazard this row used to describe is gone and the correction matters more than the gap:** neither is in the known-command allowlist and `require "reject"` is refused, so a script that tries to reject mail is rejected AT UPLOAD with a message naming the unsupported extension. The author is told; mail is not silently kept while they believe it is being refused. `SieveSyntax.cs` asserts exactly that script is refused. Implementing them means an SMTP-time refusal for `ereject` and an RFC 3834 bounce for `reject`. |
511511
|| relational (RFC 5231) | Not implemented. No :count or :value match types, and no i;ascii-numeric comparator to make them meaningful — SplitArguments recognises only is/contains/matches **Shipped in dc9301a.** |
512512
|| RENAMESCRIPT and UNAUTHENTICATE | Neither implemented; both fall through to NO "Unknown command." A client renaming a script must GETSCRIPT/PUTSCRIPT/DELETESCRIPT by hand |

hmailserver/source/Server/Common/Rules/RuleGuard.cpp

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,75 @@ namespace HM
330330
return false;
331331
}
332332

333+
bool
334+
RuleGuard::SieveRegexMatches(const String &pattern, const String &subject, bool caseSensitive)
335+
{
336+
if (PatternIsSuspended_(pattern))
337+
return false;
338+
339+
bool compiled = false;
340+
341+
try
342+
{
343+
// Perl syntax, as for a rule criterion; icase is what the script's default
344+
// comparator (i;ascii-casemap) means, and i;octet turns it off.
345+
boost::wregex expression(pattern,
346+
caseSensitive ? boost::regex::normal : (boost::regex::normal | boost::regex::icase));
347+
compiled = true;
348+
349+
const ULONGLONG startTick = ::GetTickCount64();
350+
351+
// regex_search, not regex_match: draft-ietf-sieve-regex matches anywhere in
352+
// the value, and the script anchors with ^ and $ when it means the whole.
353+
const bool matched = boost::regex_search(subject, expression);
354+
355+
const ULONGLONG elapsed = ::GetTickCount64() - startTick;
356+
357+
if (elapsed >= static_cast<ULONGLONG>(EvaluationBudgetMilliseconds) &&
358+
SuspendPatternAndClaimReport_(pattern))
359+
{
360+
String errorMessage = Formatter::Format("A Sieve ':regex' match took {0} ms against a {1} character value, over the {2} ms budget, and will not be evaluated again for {3} seconds. Any sender can make this cost recur once per message, so it is being paid once per pattern instead. Pattern begins: {4}",
361+
elapsed, subject.GetLength(), EvaluationBudgetMilliseconds, SuspensionSeconds, DescribePattern_(pattern));
362+
363+
ErrorManager::Instance()->ReportError(ErrorManager::Medium, 6043, "RuleGuard::SieveRegexMatches", errorMessage);
364+
}
365+
366+
return matched;
367+
}
368+
catch (const std::runtime_error &)
369+
{
370+
if (!compiled)
371+
{
372+
// Upload validation compiles every ':regex' key, so reaching this means a
373+
// script stored before that check existed, or edited on disk. Saying so
374+
// matters for the same reason it does for a rule: a test that can never
375+
// match looks exactly like a test that happens not to.
376+
if (SuspendPatternAndClaimReport_(pattern))
377+
{
378+
String errorMessage = Formatter::Format("A Sieve ':regex' key is not a valid regular expression and can never match. The script containing it is active and that test is doing nothing. Pattern: {0}",
379+
DescribePattern_(pattern));
380+
381+
ErrorManager::Instance()->ReportError(ErrorManager::Medium, 6042, "RuleGuard::SieveRegexMatches", errorMessage);
382+
}
383+
384+
return false;
385+
}
386+
387+
if (SuspendPatternAndClaimReport_(pattern))
388+
{
389+
String errorMessage = Formatter::Format("A Sieve ':regex' match was abandoned as too complex against a {0} character value, and will not be evaluated again for {1} seconds. Treated as no match. Pattern begins: {2}",
390+
subject.GetLength(), SuspensionSeconds, DescribePattern_(pattern));
391+
392+
ErrorManager::Instance()->ReportError(ErrorManager::Medium, 6043, "RuleGuard::SieveRegexMatches", errorMessage);
393+
}
394+
395+
return false;
396+
}
397+
398+
// Unreachable, for the same /WX reason as in RegexCriteriaMatches.
399+
return false;
400+
}
401+
333402
void
334403
RuleGuard::ReportActionFailed(const String &actionName, const String &detail)
335404
{

hmailserver/source/Server/Common/Rules/RuleGuard.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,22 @@ namespace HM
139139
// this into a log flood of its own.
140140
static bool RegexCriteriaMatches(const String &pattern, const String &subject);
141141

142+
// The same breaker for the Sieve ":regex" match type (draft-ietf-sieve-regex),
143+
// which differs from a rule criterion in exactly two ways: it is a SEARCH
144+
// (the pattern may hit anywhere in the value; a rule criterion must match the
145+
// whole value), and its case behaviour follows the script's comparator
146+
// (i;ascii-casemap folds case, i;octet does not) rather than being fixed.
147+
//
148+
// Deliberately a parallel sibling of RegexCriteriaMatches rather than a shared
149+
// core: the two share the suspension table, the budget and the error codes
150+
// (HM6042/HM6043), but each keeps its own exact log wording, and the rules
151+
// path stays character-for-character what it was. If you change one, look at
152+
// the other. Suspension is keyed on the pattern text alone, so a pattern
153+
// suspended by a rule is also suspended for scripts and vice versa - the
154+
// breaker's question is "is this pattern affordable", which does not depend
155+
// on who asked.
156+
static bool SieveRegexMatches(const String &pattern, const String &subject, bool caseSensitive);
157+
142158
// A rule action failed and may have left the message file in a state nobody
143159
// chose.
144160
//

hmailserver/source/Server/Common/Sieve/ManageSieveServer.cpp

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,17 @@ namespace HM
234234
// delivered message was filed, with the negative control being a
235235
// mailboxexists on a folder that is absent: the message must stay in INBOX,
236236
// which an implementation answering "true" for everything cannot pass.
237-
const char *AdvertisedSieveExtensions = "fileinto copy relational subaddress vacation vacation-seconds imap4flags body mailbox";
237+
// regex moved in on 16 August 2026, under the same rule. Its delivery-side
238+
// step is MatchValue_'s dispatch into RuleGuard::SieveRegexMatches - the same
239+
// budget-and-suspend breaker the legacy rules engine's regex criterion runs
240+
// under, so a catastrophic pattern costs one 250 ms evaluation per five-minute
241+
// window rather than one per message, and delivery always continues. Keys are
242+
// compiled at upload, so a pattern that cannot compile is refused with a line
243+
// number instead of silently never matching. End-to-end tests in
244+
// SieveRegexDelivery.cs include the one that matters for the breaker: a
245+
// message whose crafted subject makes the pattern catastrophic is still
246+
// DELIVERED, to INBOX, with the pattern suspended rather than the thread lost.
247+
const char *AdvertisedSieveExtensions = "fileinto copy relational subaddress vacation vacation-seconds imap4flags body mailbox regex";
238248

239249
AnsiString EscapeQuoted(const String &value)
240250
{

hmailserver/source/Server/Common/Sieve/SieveEvaluator.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include "SieveEvaluator.h"
77

88
#include "../Util/Parsing/StringParser.h"
9+
#include "../Rules/RuleGuard.h"
910

1011
#ifdef _DEBUG
1112
#define DEBUG_NEW new(_NORMAL_BLOCK, __FILE__, __LINE__)
@@ -1132,6 +1133,15 @@ namespace HM
11321133
bool
11331134
SieveEvaluator::MatchValue_(const String &matchType, bool caseSensitive, const String &value, const String &key)
11341135
{
1136+
if (matchType == _T("regex"))
1137+
{
1138+
// draft-ietf-sieve-regex: a search, case-folded under the default
1139+
// comparator. Runs under RuleGuard's budget-and-suspend breaker, because a
1140+
// script author is exactly as able to write a catastrophic pattern as a
1141+
// rule author, and this is the delivery thread.
1142+
return RuleGuard::SieveRegexMatches(key, value, caseSensitive);
1143+
}
1144+
11351145
if (matchType == _T("contains"))
11361146
{
11371147
if (caseSensitive)

hmailserver/source/Server/Common/Sieve/SieveParser.cpp

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55

66
#include "SieveParser.h"
77

8+
// For the upload-time compile check on ':regex' keys. The evaluator matches with
9+
// Boost too (through RuleGuard), so what compiles here is what will run there.
10+
#include <boost/regex.hpp>
11+
812
#ifdef _DEBUG
913
#define DEBUG_NEW new(_NORMAL_BLOCK, __FILE__, __LINE__)
1014
#define new DEBUG_NEW
@@ -48,6 +52,22 @@ namespace HM
4852
comparator.Compare(_T("i;octet")) == 0 ||
4953
comparator.Compare(_T("i;ascii-numeric")) == 0;
5054
}
55+
56+
// Whether a ':regex' key compiles, using the same construction the evaluator
57+
// will use (Boost, Perl syntax). Called at upload validation so a bad pattern
58+
// is refused with a line number instead of silently never matching.
59+
bool RegexKeyCompiles_(const String &key)
60+
{
61+
try
62+
{
63+
boost::wregex expression(key);
64+
return true;
65+
}
66+
catch (const std::runtime_error &)
67+
{
68+
return false;
69+
}
70+
}
5171
}
5272

5373
SieveParser::SieveParser() :
@@ -102,6 +122,11 @@ namespace HM
102122
L"copy",
103123
L"relational",
104124
L"subaddress",
125+
// draft-ietf-sieve-regex. Never became an RFC, but Dovecot's Pigeonhole
126+
// implements it and clients offer it, which is what a capability name is
127+
// for. The evaluation runs under the same budget-and-suspend breaker the
128+
// legacy rules engine's regex criterion uses (RuleGuard).
129+
L"regex",
105130
L"comparator-i;ascii-casemap",
106131
L"comparator-i;octet",
107132
L"comparator-i;ascii-numeric"
@@ -229,7 +254,8 @@ namespace HM
229254

230255
if (!TagTakesValue(tag))
231256
{
232-
if (tag == _T("is") || tag == _T("contains") || tag == _T("matches"))
257+
if (tag == _T("is") || tag == _T("contains") || tag == _T("matches") ||
258+
tag == _T("regex"))
233259
{
234260
result.matchType = tag;
235261
result.matchTypeGiven = true;
@@ -857,14 +883,38 @@ namespace HM
857883
}
858884
}
859885
else if (set.comparatorGiven && set.comparator.Compare(_T("i;ascii-numeric")) == 0 &&
860-
(set.matchType == _T("contains") || set.matchType == _T("matches")))
886+
(set.matchType == _T("contains") || set.matchType == _T("matches") ||
887+
set.matchType == _T("regex")))
861888
{
862889
// i;ascii-numeric only defines equality (RFC 4790 9.1.1).
863890
errorMessage.Format(_T("Line %d: the \"i;ascii-numeric\" comparator cannot be used with ':%s'."),
864891
line, set.matchType.c_str());
865892
return false;
866893
}
867894

895+
if (set.matchType == _T("regex"))
896+
{
897+
if (!NeedExtension_(_T("regex"), _T("':regex'"), line, errorMessage))
898+
return false;
899+
900+
// Compile every key NOW, so the author hears about a bad pattern at upload
901+
// rather than the test silently never matching at delivery. The keys are
902+
// the last positional list; the earlier one, when there are two, is the
903+
// header/part names, which are not patterns.
904+
if (!set.stringLists.empty())
905+
{
906+
for (const String &key : set.stringLists.back())
907+
{
908+
if (!RegexKeyCompiles_(key))
909+
{
910+
errorMessage.Format(_T("Line %d: ':regex' key '%s' is not a valid regular expression."),
911+
line, key.c_str());
912+
return false;
913+
}
914+
}
915+
}
916+
}
917+
868918
if (set.addressPart == _T("user") || set.addressPart == _T("detail"))
869919
{
870920
if (!NeedExtension_(_T("subaddress"), _T("':") + set.addressPart + _T("'"), line, errorMessage))
@@ -1241,7 +1291,7 @@ namespace HM
12411291

12421292
if (name == _T("header"))
12431293
{
1244-
if (!CheckTags_(set, _T("comparator is contains matches value count"), _T("'header'"), errorMessage))
1294+
if (!CheckTags_(set, _T("comparator is contains matches value count regex"), _T("'header'"), errorMessage))
12451295
return false;
12461296

12471297
return ValidateMatchArguments_(set, _T("'header'"), test->line, errorMessage);
@@ -1253,7 +1303,7 @@ namespace HM
12531303
!NeedExtension_(_T("envelope"), _T("the 'envelope' test"), test->line, errorMessage))
12541304
return false;
12551305

1256-
if (!CheckTags_(set, _T("comparator is contains matches value count all localpart domain user detail"),
1306+
if (!CheckTags_(set, _T("comparator is contains matches value count regex all localpart domain user detail"),
12571307
_T("'") + name + _T("'"), errorMessage))
12581308
return false;
12591309

@@ -1300,7 +1350,7 @@ namespace HM
13001350
if (!NeedExtension_(_T("body"), _T("the 'body' test"), test->line, errorMessage))
13011351
return false;
13021352

1303-
if (!CheckTags_(set, _T("comparator is contains matches value count raw text content"),
1353+
if (!CheckTags_(set, _T("comparator is contains matches value count regex raw text content"),
13041354
_T("'body'"), errorMessage))
13051355
return false;
13061356

@@ -1331,7 +1381,7 @@ namespace HM
13311381
if (!NeedExtension_(_T("imap4flags"), _T("the 'hasflag' test"), test->line, errorMessage))
13321382
return false;
13331383

1334-
if (!CheckTags_(set, _T("comparator is contains matches value count"), _T("'hasflag'"), errorMessage))
1384+
if (!CheckTags_(set, _T("comparator is contains matches value count regex"), _T("'hasflag'"), errorMessage))
13351385
return false;
13361386

13371387
if (set.comparatorGiven && !IsKnownComparator(set.comparator))

hmailserver/test/RegressionTests/RegressionTests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@
350350
<Compile Include="Sieve\SieveBodyDelivery.cs" />
351351
<Compile Include="Sieve\SieveFlagDelivery.cs" />
352352
<Compile Include="Sieve\SieveMailboxDelivery.cs" />
353+
<Compile Include="Sieve\SieveRegexDelivery.cs" />
353354
<Compile Include="Sieve\SieveVacationDelivery.cs" />
354355
<Compile Include="Sieve\ManageSieveSecurity.cs" />
355356
<Compile Include="Sieve\SieveSyntax.cs" />

hmailserver/test/RegressionTests/Sieve/ManageSieve.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,11 @@ public void TestManageSieveRoundTrip()
247247
// delivery created.
248248
StringAssert.Contains("mailbox", greeting);
249249

250+
// regex followed the same day, with SieveRegexDelivery.cs behind it -
251+
// including the breaker test: a catastrophic pattern costs a bounded
252+
// evaluation and a suspension, never the message.
253+
StringAssert.Contains("regex", greeting);
254+
250255
// Still absent on purpose: envelope is implemented and evaluated, and is
251256
// held back until a test proves the envelope TEST command itself works.
252257
// If somebody adds it here, that test should exist first.

0 commit comments

Comments
 (0)