]> git.proxmox.com Git - pmg-api.git/blob - src/PMG/API2/MailTracker.pm
d512dd99d52fd0c79f5e2c899f7f54495516de47
[pmg-api.git] / src / PMG / API2 / MailTracker.pm
1 package PMG::API2::MailTracker;
2
3 use strict;
4 use warnings;
5 use POSIX;
6 use Digest::MD5;
7 use Time::Zone;
8 use Data::Dumper;
9 use Encode;
10
11 use PVE::Tools;
12 use PVE::SafeSyslog;
13 use PVE::RESTHandler;
14 use PVE::Exception qw(raise_param_exc);
15 use PVE::JSONSchema qw(get_standard_option);
16
17 use PMG::RESTEnvironment;
18
19 use base qw(PVE::RESTHandler);
20
21 my $get_start_end_time = sub {
22 my ($param) = @_;
23 my $start = $param->{starttime} // (time - 86400);
24 my $end = $param->{endtime} // ($start + 86400);
25 raise_param_exc({'endtime' => "must be newer than 'starttime'"}) if $start > $end;
26 return ($start, $end);
27 };
28
29 my $statmap = {
30 2 => 'delivered',
31 4 => 'deferred',
32 5 => 'bounced',
33 N => 'rejected',
34 G => 'greylisted',
35 A => 'accepted',
36 B => 'blocked',
37 Q => 'quarantine',
38 };
39
40 my $run_pmg_log_tracker = sub {
41 my ($args, $includelog) = @_;
42
43 my $logids = {};
44
45 my $timezone = tz_local_offset();;
46
47 if (defined(my $id = $includelog)) {
48 if ($id =~ m/^Q([a-f0-9]+)R([a-f0-9]+)$/i) {
49 $logids->{$1} = 1;
50 $logids->{$2} = 1;
51 push @$args, '-q', $1, '-q', $2;
52 } else {
53 $logids->{$id} = 1;
54 push @$args, '-q', $id;
55 }
56 }
57
58 my $lookup_hash = {};
59 my $list = [];
60 my $state = 'start';
61 my $status;
62 my $entry;
63 my $logs = [];
64
65 my $parser = sub {
66 my ($line) = @_;
67
68 # assume syslog is UTF-8 encoded
69 $line = decode('UTF-8', $line);
70
71 if ($state eq 'start') {
72
73 return if $line =~ m/^\#/;
74 return if $line =~ m/^\s*$/;
75
76 if ($line =~ m/^STATUS: (.*)$/) {
77 $state = 'end';
78 $status = $1;
79 return;
80 }
81
82 if ($line =~ m/^SMTPD:\s+(T[0-9A-F]+L[0-9A-F]+)$/) {
83 $state = 'smtp';
84 $entry = { id => $1 };
85 return;
86 }
87
88 if ($line =~ m/^QENTRY:\s+([0-9A-F]+)$/) {
89 $state = 'qentry';
90 $entry = { qid => $1 };
91 return;
92 }
93
94 die "got unexpected data: $line";
95 } elsif ($state eq 'end') {
96 die "got unexpected data after status: $line";
97 } elsif ($state eq 'skiplogs') {
98 if ($line =~ m/^\s*$/) {
99 $entry = undef;
100 $state = 'start';
101 } else {
102 # skip
103 }
104 } elsif ($state eq 'logs') {
105 if ($line =~ m/^\s*$/) {
106 $entry = undef;
107 $state = 'start';
108 } elsif ($line =~ m/^(SMTP|FILTER|QMGR):/) {
109 # skip
110 } elsif ($line =~ m/^(L[A-F0-9]+)\s(.*)$/) {
111 push @$logs, { linenr => $1, text => $2 };
112 } else {
113 die "got unexpected data: $line";
114 }
115 } elsif ($state eq 'qentry') {
116 if ($line =~ m/^\s*$/) {
117 $entry = undef;
118 $state = 'start';
119 } elsif ($line =~ m/^SIZE:\s+(\d+)$/) {
120 $entry->{size} = $1;
121 } elsif ($line =~ m/^CLIENT:\s+(\S+)$/) {
122 $entry->{client} = $1;
123 } elsif ($line =~ m/^MSGID:\s+(\S+)$/) {
124 $entry->{msgid} = $1;
125 } elsif ($line =~ m/^CTIME:\s+([0-9A-F]+)$/) {
126 # ignore ?
127 } elsif ($line =~ m/^TO:([0-9A-F]+):([0-9A-F]+):([0-9A-Z]):\s+from <([^>]*)>\s+to\s+<([^>]+)>\s+\((\S+)\)$/) {
128 my $new = {};
129 $new->{size} = $entry->{size} // 0,
130 $new->{client} = $entry->{client} if defined($entry->{client});
131 $new->{msgid} = $entry->{msgid} if defined($entry->{msgid});
132 $new->{time} = hex($1) - $timezone;
133 $new->{qid} = $2;
134 $new->{dstatus} = $3;
135 $new->{from} = $4;
136 $new->{to} = $5;
137 $new->{relay} = $6;
138
139 push @$list, $new;
140 $lookup_hash->{$2}->{$5} = $new;
141 } elsif ($line =~ m/^(SMTP|FILTER|QMGR):/) {
142 if ($logids->{$entry->{qid}}) {
143 $state = 'logs';
144 } else {
145 $state = 'skiplogs';
146 }
147 } else {
148 die "got unexpected data: $line";
149 }
150 } elsif ($state eq 'smtp') {
151
152 if ($line =~ m/^\s*$/) {
153 $entry = undef;
154 $state = 'start';
155 } elsif ($line =~ m/^CLIENT:\s+(\S+)$/) {
156 $entry->{client} = $1;
157 } elsif ($line =~ m/^CTIME:\s+([0-9A-F]+)$/) {
158 # ignore ?
159 } elsif ($line =~ m/^TO:([0-9A-F]+):(T[0-9A-F]+L[0-9A-F]+):([0-9A-Z]):\s+from <([^>]*)>\s+to\s+<([^>]+)>$/) {
160 my $e = {};
161 $e->{client} = $entry->{client} if defined($entry->{client});
162 $e->{time} = hex($1) - $timezone;
163 $e->{id} = $2;
164 $e->{dstatus} = $3;
165 $e->{from} = $4;
166 $e->{to} = $5;
167 push @$list, $e;
168 } elsif ($line =~ m/^LOGS:$/) {
169 if ($logids->{$entry->{id}}) {
170 $state = 'logs';
171 } else {
172 $state = 'skiplogs';
173 }
174 } else {
175 die "got unexpected data: $line";
176 }
177 } else {
178 die "unknown state '$state'\n";
179 }
180 };
181
182 my $cmd = ['/usr/bin/pmg-log-tracker', '-v', '-l', 2000];
183
184 PVE::Tools::run_command([@$cmd, @$args], timeout => 25, outfunc => $parser);
185
186 my $sorted_logs = [];
187 foreach my $le (sort {$a->{linenr} cmp $b->{linenr}} @$logs) {
188 push @$sorted_logs, $le->{text};
189 }
190
191 foreach my $e (@$list) {
192 if (my $id = $e->{qid}) {
193 if (my $relay = $e->{relay}) {
194 if (my $ref = $lookup_hash->{$relay}->{$e->{to}}) {
195 $ref->{is_relay} = 1;
196 $id = 'Q' . $e->{qid} . 'R' . $e->{relay};
197 if ($e->{dstatus} eq 'A') {
198 $e->{rstatus} = $ref->{dstatus};
199 }
200 }
201 }
202 $e->{id} = $id;
203 }
204 if ($includelog && ($e->{id} eq $includelog)) {
205 $e->{logs} = $sorted_logs;
206 }
207 }
208
209 return wantarray ? ($list, $status) : $list;
210 };
211
212 my $email_log_property_desc = {
213 id => {
214 description => "Unique ID.",
215 type => 'string',
216 },
217 from => {
218 description => "Sender email address.",
219 type => 'string',
220 },
221 to => {
222 description => "Receiver email address.",
223 type => 'string',
224 },
225 qid => {
226 description => "Postfix qmgr ID.",
227 type => 'string',
228 optional => 1,
229 },
230 time => {
231 description => "Delivery timestamp.",
232 type => 'integer',
233 },
234 dstatus => {
235 description => "Delivery status.",
236 type => 'string',
237 minLength => 1,
238 maxLength => 1,
239 },
240 rstatus => {
241 description => "Delivery status of relayed mail.",
242 type => 'string',
243 minLength => 1,
244 maxLength => 1,
245 optional => 1,
246 },
247 relay => {
248 description => "ID of relayed mail.",
249 type => 'string',
250 optional => 1,
251 },
252 size => {
253 description => "The size of the raw email.",
254 type => 'number',
255 optional => 1,
256 },
257 client => {
258 description => "Client address",
259 type => 'string',
260 optional => 1,
261 },
262 msgid => {
263 description => "SMTP message ID.",
264 type => 'string',
265 optional => 1,
266 },
267 };
268
269 __PACKAGE__->register_method({
270 name => 'list_mails',
271 path => '',
272 method => 'GET',
273 description => "Read mail list.",
274 proxyto => 'node',
275 protected => 1,
276 permissions => { check => [ 'admin', 'audit' ] },
277 parameters => {
278 additionalProperties => 0,
279 properties => {
280 node => get_standard_option('pve-node'),
281 starttime => get_standard_option('pmg-starttime'),
282 endtime => get_standard_option('pmg-endtime'),
283 xfilter => {
284 description => "Only include mails containing this filter string.",
285 type => 'string',
286 minLength => 1,
287 maxLength => 256,
288 optional => 1,
289 },
290 from => {
291 description => "Sender email address filter.",
292 type => 'string',
293 optional => 1,
294 minLength => 1,
295 maxLength => 256,
296 },
297 target => {
298 description => "Receiver email address filter.",
299 type => 'string',
300 optional => 1,
301 minLength => 1,
302 maxLength => 256,
303 },
304 ndr => {
305 description => "Include NDRs (non delivery reports).",
306 type => 'boolean',
307 optional => 1,
308 default => 0,
309 },
310 greylist => {
311 description => "Include Greylisted entries.",
312 type => 'boolean',
313 optional => 1,
314 default => 0,
315 },
316 },
317 },
318 returns => {
319 type => 'array',
320 items => {
321 type => "object",
322 properties => $email_log_property_desc,
323 },
324 links => [ { rel => 'child', href => "{id}" } ],
325 },
326 code => sub {
327 my ($param) = @_;
328
329 my $restenv = PMG::RESTEnvironment->get();
330
331 my $args = [];
332
333 my ($start, $end) = $get_start_end_time->($param);
334
335 push @$args, '-s', $start;
336 push @$args, '-e', $end;
337
338 push @$args, '-n' if !$param->{ndr};
339
340 push @$args, '-g' if !$param->{greylist};
341
342 push @$args, '-x', $param->{xfilter} if defined($param->{xfilter});
343
344 if (defined($param->{from})) {
345 push @$args, '-f', $param->{from};
346 }
347 if (defined($param->{target})) {
348 push @$args, '-t', $param->{target};
349 }
350
351 my ($list, $status) = $run_pmg_log_tracker->($args);
352
353 my $res = [];
354 foreach my $e (@$list) {
355 push @$res, $e if !$e->{is_relay};
356 }
357
358 # hack: return status message in 'changes' attribute
359 $restenv->set_result_attrib('changes', $status) if defined($status);
360
361 return $res;
362 }});
363
364 __PACKAGE__->register_method({
365 name => 'maillog',
366 path => '{id}',
367 method => 'GET',
368 description => "Get the detailed syslog entries for a specific mail ID.",
369 proxyto => 'node',
370 protected => 1,
371 permissions => { check => [ 'admin', 'audit' ] },
372 parameters => {
373 additionalProperties => 0,
374 properties => {
375 node => get_standard_option('pve-node'),
376 starttime => get_standard_option('pmg-starttime'),
377 endtime => get_standard_option('pmg-endtime'),
378 id => {
379 description => "Mail ID (as returend by the list API).",
380 type => 'string',
381 minLength => 3,
382 maxLength => 64,
383 },
384 },
385 },
386 returns => {
387 type => "object",
388 properties => {
389 %$email_log_property_desc,
390 logs => {
391 type => 'array',
392 items => { type => "string" },
393 }
394 },
395 },
396 code => sub {
397 my ($param) = @_;
398
399 my $restenv = PMG::RESTEnvironment->get();
400
401 my $args = ['-v'];
402
403 my ($start, $end) = $get_start_end_time->($param);
404
405 push @$args, '-s', $start;
406 push @$args, '-e', $end;
407
408 my $list = $run_pmg_log_tracker->($args, $param->{id});
409
410 my $res;
411 foreach my $e (@$list) {
412 $res = $e if $e->{id} eq $param->{id};
413 }
414
415 die "entry '$param->{id}' not found\n" if !defined($res);
416
417 return $res;
418 }});
419
420 1;