receiver: better filter processing
[chiroptera.git] / chibackend / mfilter.d
blob3992fafa9ea002af80525c8f8c72323556a33040
1 /* E-Mail Client
2 * coded by Ketmar // Invisible Vector <ketmar@ketmar.no-ip.org>
3 * Understanding is not required. Only obedience.
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, version 3 of the License ONLY.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <http://www.gnu.org/licenses/>.
17 module chibackend.mfilter is aliced;
19 import iv.cmdcon;
20 import iv.strex;
21 import chibackend : DynStr;
24 // ////////////////////////////////////////////////////////////////////////// //
25 mixin(NewExceptionClass!("FilterSyntaxException", "Exception"));
28 // ////////////////////////////////////////////////////////////////////////// //
29 class FilterHelper {
30 enum Action {
31 Nothing, // do nothing
32 Delete, // delete message
33 SoftDelete, // "soft-delete" message (mark as read, strike through, but don't remove)
34 Spam, // mark message as spam
35 Ham, // mark message as ham
36 Read, // mark message as read
37 Stop, // do not process other filters
38 Move, // <tagname> -- move message to the specified tag
39 Exec, // <command>
42 // called if a filter was matched
43 abstract void filterMatched ();
45 abstract DynStr getAccount ();
47 abstract DynStr getHeaderField (const(char)[] header, out bool exists);
49 abstract DynStr getFromName ();
50 abstract DynStr getFromMail ();
51 abstract DynStr getToName ();
52 abstract DynStr getToMail ();
53 abstract DynStr getSubj (out bool exists);
55 // returns first string from stdout
56 // it should be action name
57 abstract DynStr exec (const(char)[] cmd);
59 abstract void move (const(char)[] dest);
61 // won't be called for `Nothing`, `Move`, `Exec` and `Stop`
62 abstract void performAction (Action action);
64 abstract bool match (const(char)[] pat, const(char)[] str, bool casesens);
66 static Action parseActionName (const(char)[] tok, out bool unknown) pure nothrow @trusted @nogc {
67 unknown = false;
68 tok = tok.xstrip;
69 if (tok.strEquCI("nothing") || tok.strEquCI("noop") || tok.strEquCI("nop")) return FilterHelper.Action.Nothing;
70 if (tok.strEquCI("delete")) return FilterHelper.Action.Delete;
71 if (tok.strEquCI("softdelete") || tok.strEquCI("soft_delete") || tok.strEquCI("soft-delete")) return FilterHelper.Action.SoftDelete;
72 if (tok.strEquCI("spam")) return FilterHelper.Action.Spam;
73 if (tok.strEquCI("ham")) return FilterHelper.Action.Ham;
74 if (tok.strEquCI("read")) return FilterHelper.Action.Read;
75 if (tok.strEquCI("stop")) return FilterHelper.Action.Stop;
76 if (tok.strEquCI("move")) return FilterHelper.Action.Move;
77 if (tok.strEquCI("exec")) return FilterHelper.Action.Exec;
78 unknown = true;
79 return FilterHelper.Action.Nothing;
84 // ////////////////////////////////////////////////////////////////////////// //
85 // filter "xx"
86 // (frommail matchcase "abc@def" OR from-mail matchcase "gex@boo") AND from-name match "zoo" move "0rare/doo" ham stop
87 // returns `false` if no more filters should be processed ("stop" action)
88 // pass `null` as `hlp` to perform syntax check only
89 public bool executeMailFilter (const(char)[] filter, FilterHelper hlp) {
90 char[256] temptoken = void;
91 const(char)[] tok;
92 auto anchor = filter;
94 // returns false if there are no more tokens
95 bool getToken () {
96 tok = null;
97 while (filter.length && filter[0] <= ' ') filter = filter[1..$];
98 if (filter.length == 0) return false;
99 char ch = filter[0];
100 if (ch == '#') return false;
101 if (ch == '"' || ch == '\'') {
102 // quoted string
103 immutable char qch = ch;
104 filter = filter[1..$];
105 usize pos = 0;
106 while (filter.length) {
107 ch = filter[0];
108 filter = filter[1..$];
109 if (ch != '\\') {
110 if (ch == qch) break;
111 } else {
112 // escape
113 if (filter.length == 0) throw new FilterSyntaxException("unfinished escape");
114 ch = filter[0];
115 filter = filter[1..$];
116 if (ch == 'x' || ch == 'X' || ch == 'u' || ch == 'U') throw new FilterSyntaxException("charcode escapes are not supported");
118 if (pos >= temptoken.length) throw new FilterSyntaxException("quoted string too long");
119 temptoken[pos++] = ch;
121 tok = temptoken[0..pos];
122 } else if ("()[]!".indexOf(filter[0]) >= 0) {
123 // delimiter
124 tok = filter[0..1];
125 filter = filter[1..$];
126 } else if (filter[0] == '&') {
127 tok = "&&";
128 filter = filter[(filter.length > 1 && filter[1] == '&' ? 2 : 1)..$];
129 } else if (filter[0] == '|') {
130 tok = "||";
131 filter = filter[(filter.length > 1 && filter[1] == '|' ? 2 : 1)..$];
132 } else if (filter[0] == '-' && (filter.startsWith("-->") || filter.startsWith("->"))) {
133 // special delimiter
134 tok = "-->";
135 filter = filter[(filter[1] == '>' ? 2 : 3)..$];
136 } else {
137 // just a token until a space or a special char
138 usize pos = 1;
139 while (pos < filter.length) {
140 ch = filter[pos];
141 if (ch <= 32 || `"'()[]!&|`.indexOf(ch) >= 0) break;
142 if (ch == '-') {
143 if (filter[pos..$].startsWith("->") || filter[pos..$].startsWith("-->")) break;
145 ++pos;
147 tok = filter[0..pos];
148 filter = filter[pos..$];
150 //{ import iv.vfs.io; writeln("TOKEN: <", tok, ">"); }
151 return true;
154 bool delegate (bool doskip) parseExpr;
156 // parse expression
157 // `tok` must be valid
159 <simpleexpr> is:
160 from-name <checker>
161 from-mail <checker>
162 from <checker>
163 to-name <checker>
164 to-mail <checker>
165 to <checker>
166 subj <checker>
167 header <checker>
169 <checker> is:
170 [not] {match|matchcase} <pattern>
171 [not] exists -- because checking for empty pattern is not enough ;-)
173 bool parseExprSimple (bool doskip) {
174 if (tok is null) throw new FilterSyntaxException("unexpected end of expression");
175 bool res = true;
177 // subexpression?
178 if (tok == "(") {
179 getToken();
180 res = parseExpr(doskip);
181 if (tok != ")") throw new FilterSyntaxException("missing closing paren");
182 getToken();
183 return res;
186 // simple matcher
187 enum Cmd {
188 FromName,
189 FromMail,
190 From,
191 ToName,
192 ToMail,
194 Subj,
195 Account,
196 Header,
198 Cmd cmd = Cmd.Header;
199 if (tok.strEquCI("fromname") || tok.strEquCI("from-name") || tok.strEquCI("from_name")) cmd = Cmd.FromName;
200 else if (tok.strEquCI("frommail") || tok.strEquCI("from-mail") || tok.strEquCI("from_mail")) cmd = Cmd.FromMail;
201 else if (tok.strEquCI("toname") || tok.strEquCI("to-name") || tok.strEquCI("to_name")) cmd = Cmd.ToName;
202 else if (tok.strEquCI("tomail") || tok.strEquCI("to-mail") || tok.strEquCI("to_mail")) cmd = Cmd.ToMail;
203 else if (tok.strEquCI("from")) cmd = Cmd.From;
204 else if (tok.strEquCI("to")) cmd = Cmd.To;
205 else if (tok.strEquCI("subj") || tok.strEquCI("subject")) cmd = Cmd.Subj;
206 else if (tok.strEquCI("account")) cmd = Cmd.Account;
207 else if (tok.strEquCI("header")) cmd = Cmd.Header;
208 else throw new FilterSyntaxException("unknown filter matcher \""~tok.idup~"\"");
210 DynStr fldname;
211 if (cmd == Cmd.Header) {
212 if (!getToken()) throw new FilterSyntaxException("unexpected end of expression");
213 if (tok.length == 0) throw new FilterSyntaxException("empty field name for \"header\"");
214 fldname = tok;
217 if (!getToken()) throw new FilterSyntaxException("\"match\", \"matchcase\" or \"exists\" expected");
219 bool inverse = false;
220 if (tok.strEquCI("not")) {
221 inverse = true;
222 if (!getToken()) throw new FilterSyntaxException("\"match\", \"matchcase\" or \"exists\" expected");
225 enum Matcher {
226 Match,
227 MatchCase,
228 Exists,
230 Matcher mt = Matcher.Match;
231 if (tok.strEquCI("match")) mt = Matcher.Match;
232 else if (tok.strEquCI("matchcase") || tok.strEquCI("match_case") || tok.strEquCI("match-case")) mt = Matcher.MatchCase;
233 else if (tok.strEquCI("exists")) mt = Matcher.Exists;
234 else throw new FilterSyntaxException("\"match\", \"matchcase\" or \"exists\" expected");
236 if (mt != Matcher.Exists) {
237 if (!getToken()) throw new FilterSyntaxException("pattern expected");
238 } else {
239 getToken(); // no args
242 if (!doskip && hlp !is null) {
243 bool exists;
244 DynStr val;
245 final switch (cmd) {
246 case Cmd.FromName: val = hlp.getFromName(); exists = true; break;
247 case Cmd.FromMail: val = hlp.getFromMail(); exists = true; break;
248 case Cmd.ToName: val = hlp.getToName(); exists = true; break;
249 case Cmd.ToMail: val = hlp.getToMail(); exists = true; break;
250 case Cmd.From: val = hlp.getHeaderField("From", out exists); break;
251 case Cmd.To: val = hlp.getHeaderField("To", out exists); break;
252 case Cmd.Subj: val = hlp.getSubj(out exists); break;
253 case Cmd.Account: val = hlp.getAccount(); exists = (val.length() != 0); break;
254 case Cmd.Header: val = hlp.getHeaderField(fldname, out exists); break;
256 if (mt == Matcher.Exists) {
257 res = exists;
258 } else {
259 res = hlp.match(tok, val, (mt == Matcher.MatchCase));
261 if (inverse) res = !res;
264 getToken(); // skip pattern
265 return res;
268 bool parseExpression (bool doskip) {
269 bool res = parseExprSimple(doskip);
270 //writeln("!!!");
271 while (tok !is null) {
272 //writeln("*** <", tok, ">");
273 char cond = 0;
274 if (tok.strEquCI("or") || tok == "||") cond = '|';
275 else if (tok.strEquCI("and") || tok == "&&") cond = '&';
276 else break;
277 if (!doskip) {
278 final switch (cond) {
279 case '|': if (res) doskip = true; break;
280 case '&': if (!res) doskip = true; break;
283 if (!getToken()) throw new FilterSyntaxException("expression expected");
284 immutable bool rval = parseExprSimple(doskip);
285 if (!doskip) {
286 final switch (cond) {
287 case '|': res = (res || rval); if (res) doskip = true; break;
288 case '&': res = (res && rval); if (!res) doskip = true; break;
292 return res;
295 parseExpr = &parseExpression;
297 if (!getToken()) return true;
298 immutable bool match = parseExpr(false);
300 if (match && hlp !is null) hlp.filterMatched();
302 // actions
304 move <tagname> -- move message to the specified tag
305 delete -- delete message
306 softdelete -- "soft-delete" message (mark as read, strike through, but don't remove)
307 spam -- mark message as spam
308 ham -- mark message as ham
309 read -- mark message as read
310 exec <command> -- see below
311 stop -- do not process other filters
313 bool res = true;
314 while (tok !is null) {
315 if (tok == "-->" || tok.strEquCI("do") || tok.strEquCI("perform") || tok.strEquCI("and") || tok == "&&") {
316 getToken();
317 if (tok is null) throw new FilterSyntaxException("perform what?");
319 bool unknown;
320 FilterHelper.Action act = FilterHelper.parseActionName(tok, out unknown);
321 if (unknown) throw new FilterSyntaxException("unknown filter action \""~tok.idup~"\"");
322 getToken(); // skip action name
323 doaction:
324 if (act == FilterHelper.Action.Stop) {
325 if (match) {
326 res = false;
327 if (hlp !is null) hlp.performAction(act);
329 continue;
331 if (act == FilterHelper.Action.Nothing) continue;
332 if (act == FilterHelper.Action.Exec) {
333 if (tok is null) throw new FilterSyntaxException("unknown filter action \"exec\" expects one argument");
334 if (match) {
335 DynStr eres = (hlp !is null ? hlp.exec(tok).xstrip : "nothing");
336 const(char)[] execres = eres.getData;
337 //TODO: process "move" here!
338 immutable bool doStop = (execres.length && execres[0] == '-');
339 if (doStop) {
340 execres = execres[1..$];
341 res = false;
342 if (hlp !is null) hlp.performAction(act);
344 if (execres.startsWithCI("move")) {
345 execres = execres[4..$];
346 if (execres.length == 0 || execres[0] > 32) throw new FilterSyntaxException("filter action \"exec\" tried to move the message to the empty tag");
347 execres = execres.xstrip;
348 if (execres.length == 0) throw new FilterSyntaxException("filter action \"exec\" tried to move the message to the blank tag");
349 //throw new FilterSyntaxException("filter action \"exec\" cannot move messages yet");
350 tok = execres.dup; // need to copy due to DynStr
351 // do not skip arguments here, "move" handler will do it for us
352 goto domove;
354 getToken(); // skip arguments
355 act = FilterHelper.parseActionName(execres, out unknown);
356 if (act == FilterHelper.Action.Exec || execres.startsWithCI("exec")) throw new FilterSyntaxException("filter action \"exec\" cannot perform recursive execs");
357 goto doaction;
358 } else {
359 getToken(); // skip arguments
360 continue;
363 if (act == FilterHelper.Action.Move) {
364 domove:
365 if (tok is null) throw new FilterSyntaxException("unknown filter action \"move\" expects one argument");
366 // normalize tag
367 tok = tok.xstrip;
368 while (tok.length && tok[$-1] == '/') tok = tok[0..$-1].xstrip;
369 if (tok.length == 0) throw new FilterSyntaxException("\"move\" expects non-empty tag name");
370 if (tok.indexOf("//") >= 0) throw new FilterSyntaxException("\"move\" tag name cannot contain \"//\"");
371 if ((tok[0].isalnum || tok[0] >= 128) && tok.indexOf(':') < 0) {
372 if (match && hlp) {
373 // fix name
374 DynStr tn = "/";
375 tn ~= tok;
376 hlp.move(tn.getData);
378 } else {
379 if (match && hlp !is null) hlp.move(tok);
381 getToken(); // skip arguments
382 continue;
384 if (match && hlp !is null) hlp.performAction(act);
386 return res;