Feature: extended filter rules — attachment field + multi-value conditions
Adds an "Attachment" condition field (is present / of type <ext>) backed by the RFC 5703 Sieve mime extension, matching the filename in both Content-Disposition and Content-Type headers so real-world senders that only put the name in Content-Type (Microsoft SMTPSVC, etc.) are caught. Users type extensions (pdf, doc) not MIME types. Also makes each text condition accept comma-separated multiple values emitted as a Sieve string list (OR within the condition), so "(domain1 OR domain2) AND attachment pdf/xml" is expressible in one rule. value is now string | string[] (single-value rules stay strings -> backward compatible). New filter locale keys in all 17 locales.
This commit is contained in:
+53
-12
@@ -12,42 +12,82 @@ function escapeString(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
// Normalise the condition value to a non-empty string array. Single-value
|
||||
// conditions stay one-element; arrays are filtered for empty strings.
|
||||
function toValueList(value: string | string[]): string[] {
|
||||
const arr = Array.isArray(value) ? value : [value];
|
||||
return arr.map((v) => (v ?? '').toString()).filter((v) => v.length > 0);
|
||||
}
|
||||
|
||||
// Render one or many strings as a Sieve string-literal-or-list. Sieve treats
|
||||
// `header :contains "From" ["a", "b"]` as "any of a, b" (built-in OR within
|
||||
// the condition); the single-string form is emitted unchanged when len === 1
|
||||
// so existing scripts and tests stay byte-identical.
|
||||
function formatStringArg(values: string[], transform: (s: string) => string = (s) => s): string {
|
||||
if (values.length === 1) {
|
||||
return `"${escapeString(transform(values[0]))}"`;
|
||||
}
|
||||
return `[${values.map((v) => `"${escapeString(transform(v))}"`).join(', ')}]`;
|
||||
}
|
||||
|
||||
function generateCondition(condition: FilterCondition): string {
|
||||
const { field, comparator, value } = condition;
|
||||
|
||||
if (field === 'size') {
|
||||
// Size is numeric, single value only.
|
||||
const sizeValue = Array.isArray(value) ? value[0] : value;
|
||||
const op = comparator === 'greater_than' ? ':over' : ':under';
|
||||
return `size ${op} ${value}`;
|
||||
return `size ${op} ${sizeValue}`;
|
||||
}
|
||||
|
||||
const values = toValueList(value);
|
||||
|
||||
if (field === 'body') {
|
||||
const matchType = comparator === 'is' ? ':is' : ':contains';
|
||||
return `body ${matchType} "${escapeString(value)}"`;
|
||||
return `body ${matchType} ${formatStringArg(values)}`;
|
||||
}
|
||||
|
||||
if (field === 'attachment') {
|
||||
// RFC 5703: :mime :anychild matches against headers of any MIME part.
|
||||
// has_any tests Content-Disposition for "attachment"; has_type matches
|
||||
// the file extension against the filename across BOTH Content-Disposition
|
||||
// (filename= parameter) and Content-Type (name= parameter) - many older
|
||||
// senders (Microsoft SMTPSVC, PrintToMail, etc.) put the filename only
|
||||
// in Content-Type and leave Content-Disposition without a filename.
|
||||
// RFC 5228 §5.7 allows a string-list for header names; the test passes
|
||||
// if any listed header matches. Wildcard "*.<ext>*" catches quoted,
|
||||
// unquoted, and RFC-2231-encoded forms alike since ".<ext>" appears as
|
||||
// a literal substring in all of them.
|
||||
// Multiple extensions become a Sieve value-list ["*.pdf*", "*.xml*"]
|
||||
// = OR within the condition (any item matches → test passes).
|
||||
if (comparator === 'has_any') {
|
||||
return `header :mime :anychild :contains "Content-Disposition" "attachment"`;
|
||||
}
|
||||
const normalised = values.map((v) => v.replace(/^[.*]+/, '').trim()).filter(Boolean);
|
||||
return `header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ${formatStringArg(normalised, (ext) => `*.${ext}*`)}`;
|
||||
}
|
||||
|
||||
const headerName = field === 'header'
|
||||
? (condition.headerName || 'X-Unknown')
|
||||
: HEADER_MAP[field];
|
||||
|
||||
const escaped = escapeString(value);
|
||||
|
||||
switch (comparator) {
|
||||
case 'contains':
|
||||
return `header :contains "${headerName}" "${escaped}"`;
|
||||
return `header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||
case 'not_contains':
|
||||
return `not header :contains "${headerName}" "${escaped}"`;
|
||||
return `not header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||
case 'is':
|
||||
return `header :is "${headerName}" "${escaped}"`;
|
||||
return `header :is "${headerName}" ${formatStringArg(values)}`;
|
||||
case 'not_is':
|
||||
return `not header :is "${headerName}" "${escaped}"`;
|
||||
return `not header :is "${headerName}" ${formatStringArg(values)}`;
|
||||
case 'starts_with':
|
||||
return `header :matches "${headerName}" "${escaped}*"`;
|
||||
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `${v}*`)}`;
|
||||
case 'ends_with':
|
||||
return `header :matches "${headerName}" "*${escaped}"`;
|
||||
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `*${v}`)}`;
|
||||
case 'matches':
|
||||
return `header :matches "${headerName}" "${escaped}"`;
|
||||
return `header :matches "${headerName}" ${formatStringArg(values)}`;
|
||||
default:
|
||||
return `header :contains "${headerName}" "${escaped}"`;
|
||||
return `header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +129,7 @@ function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): s
|
||||
for (const rule of enabledRules) {
|
||||
for (const condition of rule.conditions) {
|
||||
if (condition.field === 'body') extensions.add('body');
|
||||
if (condition.field === 'attachment') extensions.add('mime');
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
switch (action.type) {
|
||||
|
||||
+133
-22
@@ -36,7 +36,11 @@ const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
|
||||
function isValidCondition(c: unknown): boolean {
|
||||
if (!c || typeof c !== 'object') return false;
|
||||
const cond = c as Record<string, unknown>;
|
||||
return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string';
|
||||
if (typeof cond.field !== 'string' || typeof cond.comparator !== 'string') return false;
|
||||
// value may be a string OR a non-empty array of strings (Patch 11
|
||||
// multi-value semantics). Accept both.
|
||||
if (typeof cond.value === 'string') return true;
|
||||
return Array.isArray(cond.value) && cond.value.every((v) => typeof v === 'string');
|
||||
}
|
||||
|
||||
function isValidAction(a: unknown): boolean {
|
||||
@@ -334,43 +338,150 @@ function parseAtom(raw: string): FilterCondition | null {
|
||||
}
|
||||
}
|
||||
|
||||
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
// Parse the value-tail of a header/body test: either a single quoted
|
||||
// string or a Sieve list literal ["a", "b", ...]. Returns the unwrapped
|
||||
// value(s), preserving the array shape when present so the caller can
|
||||
// detect multi-value conditions.
|
||||
const parseValueTail = (raw: string): string | string[] | null => {
|
||||
const trimmed = raw.trim();
|
||||
// List form
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
const inner = trimmed.slice(1, -1);
|
||||
const items: string[] = [];
|
||||
const re = /"((?:[^"\\]|\\.)*)"/g;
|
||||
let mm: RegExpExecArray | null;
|
||||
let cursor = 0;
|
||||
while ((mm = re.exec(inner)) !== null) {
|
||||
// Ensure only whitespace and commas appear between items
|
||||
if (inner.slice(cursor, mm.index).replace(/[\s,]/g, '') !== '') return null;
|
||||
items.push(unescapeSieveString(mm[1]));
|
||||
cursor = mm.index + mm[0].length;
|
||||
}
|
||||
if (inner.slice(cursor).replace(/[\s,]/g, '') !== '') return null;
|
||||
if (items.length === 0) return null;
|
||||
return items.length === 1 ? items[0] : items;
|
||||
}
|
||||
// Single string form
|
||||
const single = /^"((?:[^"\\]|\\.)*)"$/.exec(trimmed);
|
||||
if (single) return unescapeSieveString(single[1]);
|
||||
return null;
|
||||
};
|
||||
|
||||
// Classify a :matches value (or values) into starts_with / ends_with /
|
||||
// matches by inspecting wildcard positions. For multi-value, all items
|
||||
// must share the same shape; otherwise we fall back to 'matches' and
|
||||
// keep the wildcards verbatim.
|
||||
const classifyMatches = (
|
||||
values: string | string[],
|
||||
): { comparator: 'starts_with' | 'ends_with' | 'matches'; stripped: string | string[] } => {
|
||||
const arr = Array.isArray(values) ? values : [values];
|
||||
const isTrailing = (v: string) => {
|
||||
const stars = [...v].filter((c) => c === '*').length;
|
||||
return stars === 1 && v.endsWith('*');
|
||||
};
|
||||
const isLeading = (v: string) => {
|
||||
const stars = [...v].filter((c) => c === '*').length;
|
||||
return stars === 1 && v.startsWith('*');
|
||||
};
|
||||
if (arr.every(isTrailing)) {
|
||||
const stripped = arr.map((v) => v.slice(0, -1));
|
||||
return { comparator: 'starts_with', stripped: Array.isArray(values) ? stripped : stripped[0] };
|
||||
}
|
||||
if (arr.every(isLeading)) {
|
||||
const stripped = arr.map((v) => v.slice(1));
|
||||
return { comparator: 'ends_with', stripped: Array.isArray(values) ? stripped : stripped[0] };
|
||||
}
|
||||
return { comparator: 'matches', stripped: values };
|
||||
};
|
||||
|
||||
// Match attachment-aware :mime :anychild tests before the generic header
|
||||
// pattern - emitted by our own generator for field === 'attachment'.
|
||||
// has_any: ":contains Content-Disposition attachment"
|
||||
let m = /^header\s+:mime\s+:anychild\s+:contains\s+"Content-Disposition"\s+"attachment"$/.exec(s);
|
||||
if (m) {
|
||||
const [, tag, headerName, rawValue] = m;
|
||||
const value = unescapeSieveString(rawValue);
|
||||
return { field: 'attachment', comparator: 'has_any', value: '' };
|
||||
}
|
||||
// has_type: ":matches <headers> <value-tail>"
|
||||
// - Current emit form uses a header-list ["Content-Disposition", "Content-Type"]
|
||||
// to catch senders who put the filename only in Content-Type's name= param
|
||||
// (Microsoft SMTPSVC, PrintToMail.net, etc.).
|
||||
// - Legacy emit form used a single "Content-Disposition" header - still
|
||||
// recognised here so rules saved before the fix remain editable.
|
||||
// Each value item must be a "*.<ext>*" wildcard pattern.
|
||||
const tryHasType = (rawHeaders: string, rawValue: string): FilterCondition | null => {
|
||||
// Header part: accept either a single quoted string or a 2-element list
|
||||
// containing exactly Content-Disposition + Content-Type (in any order).
|
||||
const single = /^"Content-Disposition"$/.exec(rawHeaders.trim());
|
||||
const listForm = /^\[\s*((?:"(?:[^"\\]|\\.)*"\s*,?\s*)+)\]$/.exec(rawHeaders.trim());
|
||||
let headersOk = false;
|
||||
if (single) {
|
||||
headersOk = true;
|
||||
} else if (listForm) {
|
||||
const inner = listForm[1];
|
||||
const items: string[] = [];
|
||||
const re = /"((?:[^"\\]|\\.)*)"/g;
|
||||
let mm: RegExpExecArray | null;
|
||||
while ((mm = re.exec(inner)) !== null) items.push(unescapeSieveString(mm[1]));
|
||||
const expected = new Set(['Content-Disposition', 'Content-Type']);
|
||||
const got = new Set(items);
|
||||
headersOk =
|
||||
items.length === expected.size &&
|
||||
[...expected].every((h) => got.has(h));
|
||||
}
|
||||
if (!headersOk) return null;
|
||||
const tail = parseValueTail(rawValue);
|
||||
if (tail === null) return null;
|
||||
const arr = Array.isArray(tail) ? tail : [tail];
|
||||
const exts: string[] = [];
|
||||
for (const item of arr) {
|
||||
const em = /^\*\.((?:[^*\\]|\\.)+)\*$/.exec(item);
|
||||
if (!em) return null;
|
||||
exts.push(unescapeSieveString(em[1]));
|
||||
}
|
||||
return { field: 'attachment', comparator: 'has_type', value: exts.length === 1 ? exts[0] : exts };
|
||||
};
|
||||
m = /^header\s+:mime\s+:anychild\s+:matches\s+(\[[\s\S]+?\]|"[^"]+")\s+([\s\S]+)$/.exec(s);
|
||||
if (m) {
|
||||
const result = tryHasType(m[1], m[2]);
|
||||
if (result) return result;
|
||||
}
|
||||
// Unknown :mime :anychild pattern (e.g. from external scripts) - bail to
|
||||
// opaque rendering so we don't silently misrepresent the script.
|
||||
if (/^header\s+:mime\s+:anychild\b/.test(s)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
|
||||
if (m) {
|
||||
const [, tag, headerName, rawTail] = m;
|
||||
const value = parseValueTail(rawTail);
|
||||
if (value === null) return null;
|
||||
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
|
||||
|
||||
let comparator: FilterComparator;
|
||||
let finalValue: string | string[];
|
||||
if (tag === 'contains') {
|
||||
comparator = negated ? 'not_contains' : 'contains';
|
||||
finalValue = value;
|
||||
} else if (tag === 'is') {
|
||||
comparator = negated ? 'not_is' : 'is';
|
||||
finalValue = value;
|
||||
} else {
|
||||
// :matches - distinguish starts_with / ends_with / matches
|
||||
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
|
||||
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
|
||||
comparator = 'starts_with';
|
||||
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
if (starPositions.length === 1 && starPositions[0] === 0) {
|
||||
comparator = 'ends_with';
|
||||
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
comparator = 'matches';
|
||||
const classified = classifyMatches(value);
|
||||
comparator = classified.comparator;
|
||||
finalValue = classified.stripped;
|
||||
}
|
||||
|
||||
const cond: FilterCondition = { field, comparator, value };
|
||||
const cond: FilterCondition = { field, comparator, value: finalValue };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
|
||||
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
m = /^body\s+:(contains|is)\s+([\s\S]+)$/.exec(s);
|
||||
if (m) {
|
||||
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) };
|
||||
const value = parseValueTail(m[2]);
|
||||
if (value === null) return null;
|
||||
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value };
|
||||
}
|
||||
|
||||
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
|
||||
|
||||
Reference in New Issue
Block a user