Skip to content

Commit 8369ad0

Browse files
editheader: a script can rewrite the message it is filing
addheader and deleteheader (RFC 5293), advertised over ManageSieve. addheader inserts at the top of the header by default and at the bottom under :last; deleteheader removes every instance of a name, the nth under :index/:last, or only the instances whose value matches under :is/:contains/:matches with the comparator honoured. The evaluator only DECIDES. The edits travel in script order in the structured result - order matters, because "deleteheader X; addheader X" is a replace and the other way round is not - and LocalDelivery::ApplySieveHeaderEdits_ rewrites the stored file BEFORE redirects are queued, so a forwarded copy carries the edits (RFC 5293 2: an edit affects all subsequent actions), then refreshes the recorded message size so the database row agrees with the file. Failure handling follows the rules engine's header rewriting: a failed load or save is reported through RuleGuard::ReportActionFailed, and a half-parsed file is never written back - MimeLoadResult exists precisely to tell "could not open" from "parsed half way", and writing edits onto either would be worse than the edits not happening. Received and Return-Path are protected AT UPLOAD, with the refusal naming the header: the trace of how a message travelled is not a script's to edit (RFC 5293 7). Field names are validated as legal header names, so "Bad Name" fails with a line number rather than producing a header no parser will read back. The tests assert on the DELIVERED BYTES over POP3 - the file the user's client downloads - including placement above and below existing fields, with the negative control that a non-matching value pattern deletes nothing. One honest limit lives in a comment where it bites: value patterns match an RFC 2047-encoded value in its encoded form, which can only make a delete NOT happen - the safe direction. Suite: 1546/1546.
1 parent 8f03671 commit 8369ad0

11 files changed

Lines changed: 511 additions & 9 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) | 57 | 0 | 7 ||
60+
| [Sieve, ManageSieve and rules](#sieve-managesieve-and-rules) | 58 | 0 | 6 ||
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** | **632** | **12** | **136** | **15** |
75+
| **Total** | **633** | **12** | **135** | **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-
57 shipped · 0 underway · 7 not started · 0 deferred
451+
58 shipped · 0 underway · 6 not started · 0 deferred
452452

453453
| | Capability | Detail |
454454
|:-:|---|---|
@@ -498,7 +498,7 @@ the source, not from documentation.
498498
|| copy (RFC 3894) | Not implemented. There is no :copy tag on fileinto or redirect, so a redirect always cancels the implicit keep unless an explicit keep is also written **Shipped in dc9301a** - the row above described the state before it. |
499499
| ✅ | date and index (RFC 5260) | **Both shipped 16 August 2026**, advertised as "date" and "index" over ManageSieve. `date` and `currentdate` extract all thirteen RFC 5260 4.1 date-parts (year through weekday, including julian as Modified Julian Day and the composed iso8601/std11 forms), with real zone arithmetic: the header's wall clock and its zone are parsed SEPARATELY - `Time::GetDateTimeFromMimeHeader` folds them together and cannot express `:originalzone` - so `:zone` re-expresses the same instant and `:originalzone` keeps the header's own clock. The decisive test files on an hour the raw header never states (15:30 +0200 matched as hour 13 under :zone "+0000"), with the negative control that the unconverted hour must NOT also match. One trap found on the way, now recorded in the code: `Time::GetTimeAdjustForTimezone` answers "what do I add to reach UTC" - the NEGATED zone - and the first build shipped "+0200" as "-0200" until the end-to-end test caught it. An unknown date-part and a malformed :zone are refused at upload. `:index`/`:last` select among repeated header fields on the header, address and date tests (not envelope, which has nothing to index into); a missing nth instance matches nothing, not even "". |
500500
| ✅ | duplicate (RFC 7352) | **Shipped 16 August 2026.** The test in all three identifier spellings - Message-ID by default, `:header` for a named header's value, `:uniqueid` for a literal, each SOURCE-TAGGED before hashing so equal text under different spellings cannot collide (RFC 7352 3) - with `:handle` for independent windows, `:seconds` (default seven days) and `:last` measuring the window from the last occurrence. The seen-store (`SieveDuplicateTracker`) is a deliberate sibling of the vacation tracker - same file format, same SHA-256 key hashing against attacker-supplied Message-IDs forging records, same atomic sibling-write-and-rename - but it FAILS OPEN where vacation fails closed, because the common script discards on "duplicate": a wrong "duplicate" destroys a legitimate message, a wrong "new" delivers a copy twice. Unreadable store, full store (10,000 live records - no eviction, since a sender who controls their own Message-IDs could roll everyone else's records out), failed write: all answer "not a duplicate". Per-account file in the Sieve directory, removed with the account, surviving restarts. A message with no Message-ID is never a duplicate and never tracked. |
501-
|| editheader (RFC 5293) | Not implemented. addheader/deleteheader are not even in the known-command list, so a script using them fails CHECKSCRIPT with "unknown command" |
501+
| ✅ | editheader (RFC 5293) | **Shipped 16 August 2026.** `addheader` (top of the header by default, bottom under `:last`) and `deleteheader` (every instance, the nth under `:index`/`:last`, or only value-matching instances under `:is`/`:contains`/`:matches` with the comparator honoured), advertised over ManageSieve. The evaluator only DECIDES - the edits travel in script order in the structured result, and `LocalDelivery::ApplySieveHeaderEdits_` rewrites the stored file BEFORE redirects are queued, so a forwarded copy carries them (RFC 5293 2: an edit affects all subsequent actions), then refreshes the recorded message size. A failed load or write is reported through `RuleGuard::ReportActionFailed`, the same visibility the rules engine's header rewriting gets, and a half-parsed file is never written back. Received and Return-Path are protected AT UPLOAD: the trace of how a message travelled is not a script's to edit (RFC 5293 7), and the refusal names the header. Field names are validated as legal header names. End-to-end tests assert on the DELIVERED BYTES over POP3 - placement above/below existing fields included - with the negative control that a non-matching value pattern deletes nothing. One honest limit, in a comment where it bites: value patterns match an RFC 2047-encoded value in its encoded form, which can only make a delete not happen. |
502502
|| enotify (RFC 5435) | Not implemented, and refused rather than ignored: `notify` is not in the known-command allowlist, so a script using it fails at upload with "unknown command". There are no notification methods, no valid_notify_method test and no NOTIFY capability advertised over ManageSieve. |
503503
| ✅ | envelope (RFC 5228 §5.4) and body (RFC 5173) | **Both shipped.** `envelope` landed in dc9301a (the SMTP envelope reaches the evaluator, so `envelope "to"` works without the Delivered-To header). `body` landed 15 August 2026 with all three RFC 5173 transforms: `:raw` (the undecoded body, MIME structure ignored), `:text` (the default - the decoded text of every text/* part) and `:content` (the decoded parts whose MIME type matches, where "image" matches every subtype and "" matches every part). Transfer encoding and charset are decoded before matching, which is the point: a filter written against plain text keeps working when the sender's client base64-encodes the part. The MIME walk is depth-capped at 20 because it runs on the delivery thread. Advertised in the ManageSieve capability line under the standing rule - only once the delivery-side step exists - with seven end-to-end tests in `SieveBodyDelivery.cs` asserting **where a delivered message was filed** rather than what the evaluator reported, two of them negative controls (a non-matching body must stay in INBOX; a message with no body must match nothing, not even the empty key). |
504504
| ✅ | ihave (RFC 5463) and environment (RFC 5183) | **Both shipped 16 August 2026.** `ihave` answers from the SAME list `require` validates against (`SieveParser::IsSupportedExtension`), so the two can never disagree - and its dangerous half is handled: an ihave-guarded block may USE the extensions it tested for without a require line (`CollectIhaveGrants_`, scoped to the block, flowing through `allof` but deliberately not `anyof` or `not`, whose truth proves nothing about the block running). Without that grant the test is a trap - reporting an extension available that the script cannot then use. Testing for an extension this server lacks is legal at upload and evaluates false, which is the command's whole purpose. `environment` answers name/version/location(MDA)/phase(during)/host/domain truthfully and reports remote-host/remote-ip as honestly UNKNOWN - the sending client's identity does not reach the evaluator - and an unknown item matches nothing, not even the empty key. End-to-end in `SieveEnvironmentDelivery.cs`, including the grant proven by a script whose guarded block uses the body test with no require for it. |

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,15 @@ namespace HM
281281
// delivers a copy twice). End-to-end tests in SieveDuplicateDelivery.cs
282282
// deliver the same Message-ID twice and assert the first lands in INBOX and
283283
// the second where the script files duplicates.
284-
const char *AdvertisedSieveExtensions = "fileinto copy relational subaddress vacation vacation-seconds imap4flags body mailbox regex ihave environment date index spamtest spamtestplus duplicate";
284+
// editheader (RFC 5293) moved in on 16 August 2026, under the same rule. Its
285+
// delivery-side step is LocalDelivery::ApplySieveHeaderEdits_, which rewrites
286+
// the stored file with the script's addheader/deleteheader edits in script
287+
// order, BEFORE redirects are queued so forwarded copies carry them, and
288+
// refreshes the recorded size. Received and Return-Path are protected at
289+
// upload: the trace of how a message travelled is not a script's to edit.
290+
// End-to-end tests in SieveEditheaderDelivery.cs read the delivered message
291+
// over POP3 and assert on its actual bytes.
292+
const char *AdvertisedSieveExtensions = "fileinto copy relational subaddress vacation vacation-seconds imap4flags body mailbox regex ihave environment date index spamtest spamtestplus duplicate editheader";
285293

286294
AnsiString EscapeQuoted(const String &value)
287295
{

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,55 @@ namespace HM
410410
return;
411411
}
412412

413+
if (name == _T("addheader") || name == _T("deleteheader"))
414+
{
415+
// editheader (RFC 5293). The evaluator only DECIDES; the delivery path
416+
// rewrites the stored file, in script order, from these records. With no
417+
// structured result - the COM diagnostic path - the summary token below
418+
// is still produced, so a test evaluation shows what would happen.
419+
SieveArgumentSet set;
420+
String ignored;
421+
if (!SieveParser::SplitArguments(command->arguments, set, ignored))
422+
return;
423+
424+
SieveHeaderEdit edit;
425+
edit.isAdd = name == _T("addheader");
426+
427+
if (edit.isAdd)
428+
{
429+
if (set.stringLists.size() != 2 || set.stringLists[0].size() != 1 || set.stringLists[1].size() != 1)
430+
return;
431+
432+
edit.name = set.stringLists[0][0];
433+
edit.value = set.stringLists[1][0];
434+
edit.addLast = set.lastGiven;
435+
}
436+
else
437+
{
438+
if (set.stringLists.empty() || set.stringLists[0].size() != 1)
439+
return;
440+
441+
edit.name = set.stringLists[0][0];
442+
edit.indexGiven = set.indexGiven;
443+
edit.index = set.indexValue;
444+
edit.indexFromEnd = set.lastGiven && set.indexGiven;
445+
446+
if (set.stringLists.size() >= 2)
447+
{
448+
edit.patternsGiven = true;
449+
edit.patterns = set.stringLists[1];
450+
edit.matchType = set.matchTypeGiven ? set.matchType : _T("is");
451+
edit.caseSensitive = set.comparatorGiven && set.comparator.Compare(_T("i;octet")) == 0;
452+
}
453+
}
454+
455+
if (result_)
456+
result_->headerEdits.push_back(edit);
457+
458+
actions_.push_back(name + _T(":") + edit.name);
459+
return;
460+
}
461+
413462
// "require" carries no run-time behaviour.
414463
}
415464

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,34 @@ namespace HM
6666
int loopCount = 0;
6767
};
6868

69+
// One addheader or deleteheader (RFC 5293), in script order. Order matters:
70+
// "deleteheader X" then "addheader X" is a replace, the other way round is a
71+
// no-op plus an add, and the RFC has both meanings.
72+
struct SieveHeaderEdit
73+
{
74+
bool isAdd = false;
75+
76+
String name;
77+
78+
// addheader: the value to add; :last appends below existing instances
79+
// instead of prepending above them.
80+
String value;
81+
bool addLast = false;
82+
83+
// deleteheader: which instances go. indexGiven selects the nth (from the
84+
// end under indexFromEnd); with patterns given, only instances whose value
85+
// matches one of them (with the recorded match type / comparator / case
86+
// rules already resolved to a simple triple here, so the delivery side does
87+
// not need the evaluator).
88+
bool indexGiven = false;
89+
int index = 0;
90+
bool indexFromEnd = false;
91+
bool patternsGiven = false;
92+
std::vector<String> patterns;
93+
String matchType; // "is", "contains" or "matches"
94+
bool caseSensitive = false; // i;octet
95+
};
96+
6997
// Everything a script decided, for callers that need more than the ';'-joined
7098
// summary string.
7199
struct SieveResult
@@ -75,6 +103,7 @@ namespace HM
75103
bool flagsGiven = false; // the script set the local copy's IMAP flags
76104
std::vector<String> flags; // those flags, canonicalised and de-duplicated
77105
std::vector<String> redirects; // addresses to send a copy to
106+
std::vector<SieveHeaderEdit> headerEdits; // editheader (RFC 5293), in script order
78107
SieveVacationDecision vacation;
79108
};
80109

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

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ namespace HM
177177
// seen-store fails OPEN - see SieveDuplicateTracker for why that is the
178178
// opposite of the vacation tracker's direction.
179179
L"duplicate",
180+
// RFC 5293: addheader / deleteheader. Received and Return-Path are
181+
// protected - a script cannot edit the trace of how a message got here.
182+
L"editheader",
180183
L"comparator-i;ascii-casemap",
181184
L"comparator-i;octet",
182185
L"comparator-i;ascii-numeric"
@@ -493,7 +496,8 @@ namespace HM
493496
{
494497
L"require", L"if", L"elsif", L"else", L"stop", L"keep", L"discard",
495498
L"fileinto", L"redirect", L"vacation",
496-
L"setflag", L"addflag", L"removeflag"
499+
L"setflag", L"addflag", L"removeflag",
500+
L"addheader", L"deleteheader"
497501
};
498502

499503
for (const wchar_t *candidate : known)
@@ -1183,6 +1187,76 @@ namespace HM
11831187
if (!isControl && name != _T("require") && !SplitArguments(command->arguments, set, errorMessage))
11841188
return false;
11851189

1190+
if (name == _T("addheader") || name == _T("deleteheader"))
1191+
{
1192+
bool isAdd = name == _T("addheader");
1193+
1194+
if (!NeedExtension_(_T("editheader"), _T("'") + name + _T("'"), command->line, errorMessage))
1195+
return false;
1196+
1197+
if (!CheckTags_(set,
1198+
isAdd ? _T("last") : _T("index last comparator is contains matches"),
1199+
_T("'") + name + _T("'"), errorMessage))
1200+
return false;
1201+
1202+
size_t lists = set.stringLists.size();
1203+
1204+
if (isAdd)
1205+
{
1206+
if (lists != 2 || set.stringLists[0].size() != 1 || set.stringLists[1].size() != 1)
1207+
{
1208+
errorMessage.Format(_T("Line %d: 'addheader' takes a field name and a value."), command->line);
1209+
return false;
1210+
}
1211+
}
1212+
else
1213+
{
1214+
if (lists < 1 || lists > 2 || set.stringLists[0].size() != 1)
1215+
{
1216+
errorMessage.Format(_T("Line %d: 'deleteheader' takes a field name and optionally a list of value patterns."), command->line);
1217+
return false;
1218+
}
1219+
1220+
if (!ValidateIndexArguments_(set, command->line, errorMessage))
1221+
return false;
1222+
1223+
if (set.comparatorGiven && set.comparator.Compare(_T("i;ascii-numeric")) == 0)
1224+
{
1225+
errorMessage.Format(_T("Line %d: the \"i;ascii-numeric\" comparator cannot be used with 'deleteheader'."), command->line);
1226+
return false;
1227+
}
1228+
}
1229+
1230+
// The field name must be a legal header name, and the trace of how the
1231+
// message got here is not a script's to edit (RFC 5293 7).
1232+
const String &fieldName = set.stringLists[0][0];
1233+
1234+
if (fieldName.IsEmpty())
1235+
{
1236+
errorMessage.Format(_T("Line %d: the field name cannot be empty."), command->line);
1237+
return false;
1238+
}
1239+
1240+
for (int i = 0; i < fieldName.GetLength(); i++)
1241+
{
1242+
wchar_t ch = fieldName[i];
1243+
if (ch <= L' ' || ch >= 127 || ch == L':')
1244+
{
1245+
errorMessage.Format(_T("Line %d: '%s' is not a legal header field name."), command->line, fieldName.c_str());
1246+
return false;
1247+
}
1248+
}
1249+
1250+
if (fieldName.CompareNoCase(_T("Received")) == 0 || fieldName.CompareNoCase(_T("Return-Path")) == 0)
1251+
{
1252+
errorMessage.Format(_T("Line %d: the %s header records how a message travelled and cannot be edited by a script."),
1253+
command->line, fieldName.c_str());
1254+
return false;
1255+
}
1256+
1257+
return true;
1258+
}
1259+
11861260
if (name == _T("keep") || name == _T("fileinto"))
11871261
{
11881262
if (!CheckTags_(set, name == _T("fileinto") ? _T("copy flags create") : _T("flags"), _T("'") + name + _T("'"), errorMessage))

0 commit comments

Comments
 (0)