]> git.proxmox.com Git - pmg-api.git/blame - src/PMG/CLI/pmgqm.pm
dkim: add QID in warnings
[pmg-api.git] / src / PMG / CLI / pmgqm.pm
CommitLineData
fe6075cd 1package PMG::CLI::pmgqm;
9a56f771
DM
2
3use strict;
4use Data::Dumper;
6dfa0834 5use Encode qw(encode);
9a56f771
DM
6use Template;
7use MIME::Entity;
8use HTML::Entities;
9use Time::Local;
10use Clone 'clone';
11use Mail::Header;
12use POSIX qw(strftime);
7205df3e 13use File::Find;
04c66027 14use File::stat;
d9750bee 15use URI::Escape;
9a56f771
DM
16
17use PVE::SafeSyslog;
18use PVE::Tools;
19use PVE::INotify;
20use PVE::CLIHandler;
6dfa0834 21use PVE::JSONSchema qw(get_standard_option);
9a56f771
DM
22
23use PMG::RESTEnvironment;
24use PMG::Utils;
9a728eba 25use PMG::Ticket;
9a56f771
DM
26use PMG::DBTools;
27use PMG::RuleDB;
28use PMG::Config;
29use PMG::ClusterConfig;
b66faa68 30use PMG::API2::Quarantine;
9a56f771
DM
31
32use base qw(PVE::CLIHandler);
33
34sub setup_environment {
35 PMG::RESTEnvironment->setup_default_cli_env();
36}
37
9a56f771 38sub get_item_data {
1d20daff 39 my ($data, $ref) = @_;
9a56f771
DM
40
41 my @lines = split ('\n', $ref->{header});
42 my $head = new Mail::Header(\@lines);
43
1d20daff 44 my $item = {};
f46af1cf 45
666b5e8f 46 $item->{id} = sprintf("C%dR%dT%d", $ref->{cid}, $ref->{rid}, $ref->{ticketid});
252b770d 47
1d20daff 48 $item->{subject} = PMG::Utils::rfc1522_to_html(
9a56f771
DM
49 PVE::Tools::trim($head->get('subject')) || 'No Subject');
50
6d6550af 51 my $from = PMG::Utils::rfc1522_to_html(PVE::Tools::trim($head->get('from') // $ref->{sender}));
9a56f771
DM
52 my $sender = PMG::Utils::rfc1522_to_html(PVE::Tools::trim($head->get('sender')));
53
54 if ($sender) {
1d20daff
DM
55 $item->{sender} = $sender;
56 $item->{from} = sprintf ("%s on behalf of %s", $sender, $from);
9a56f771 57 } else {
1d20daff 58 $item->{from} = $from;
9a56f771
DM
59 }
60
2e35bebd 61 $item->{envelope_sender} = $ref->{sender};
6dfa0834 62 $item->{pmail} = encode_entities(PMG::Utils::try_decode_utf8($ref->{pmail}));
1d20daff 63 $item->{receiver} = $ref->{receiver} || $ref->{pmail};
9a56f771 64
1d20daff
DM
65 $item->{date} = strftime("%F", localtime($ref->{time}));
66 $item->{time} = strftime("%H:%M:%S", localtime($ref->{time}));
252b770d 67
1d20daff
DM
68 $item->{bytes} = $ref->{bytes};
69 $item->{spamlevel} = $ref->{spamlevel};
70 $item->{spaminfo} = $ref->{info};
2e35bebd 71 $item->{file} = $ref->{file};
9b0428e2 72
afd87d50 73 my $basehref = "$data->{protocol_fqdn_port}/quarantine";
ea1b7665
SI
74 if ($data->{authmode} ne 'ldap') {
75 my $ticket = uri_escape($data->{ticket});
76 $item->{href} = "$basehref?ticket=$ticket&cselect=$item->{id}&date=$item->{date}";
77 } else {
78 $item->{href} = "$basehref?cselect=$item->{id}&date=$item->{date}";
79 }
1d20daff
DM
80
81 return $item;
9a56f771
DM
82}
83
252b770d
DM
84__PACKAGE__->register_method ({
85 name => 'status',
86 path => 'status',
87 method => 'POST',
88 description => "Print quarantine status (mails per user) for specified time span.",
89 parameters => {
90 additionalProperties => 0,
91 properties => {
92 timespan => {
93 description => "Select time span.",
94 type => 'string',
95 enum => ['today', 'yesterday', 'week'],
96 default => 'today',
97 optional => 1,
98 },
99 },
100 },
101 returns => { type => 'null'},
102 code => sub {
103 my ($param) = @_;
104
105 my $cinfo = PMG::ClusterConfig->new();
106 my $role = $cinfo->{local}->{type} // '-';
107
108 if (!(($role eq '-') || ($role eq 'master'))) {
7a7e03e6
SI
109 warn "local node is not master\n";
110 return;
252b770d
DM
111 }
112
113 my $cfg = PMG::Config->new();
114
115 my $timespan = $param->{timespan} // 'today';
116
117 my ($start, $end) = PMG::Utils::lookup_timespan($timespan);
118
119 my $hostname = PVE::INotify::nodename();
120
121 my $fqdn = $cfg->get('spamquar', 'hostname') //
122 PVE::Tools::get_fqdn($hostname);
123
124
125 my $dbh = PMG::DBTools::open_ruledb();
126
127 my $domains = PVE::INotify::read_file('domains');
567516ae 128 my $domainregex = PMG::Utils::domain_regex([keys %$domains]);
252b770d
DM
129
130 my $sth = $dbh->prepare(
131 "SELECT pmail, AVG(spamlevel) as spamlevel, count(*) FROM CMailStore, CMSReceivers " .
132 "WHERE time >= $start AND time < $end AND " .
133 "QType = 'S' AND CID = CMailStore_CID AND RID = CMailStore_RID " .
134 "AND Status = 'N' " .
135 "GROUP BY pmail " .
136 "ORDER BY pmail");
137
138 $sth->execute();
139
140 print "Count Spamlevel Mail\n";
141 my $res = [];
142 while (my $ref = $sth->fetchrow_hashref()) {
143 push @$res, $ref;
144 my $extern = ($domainregex && $ref->{pmail} !~ $domainregex);
145 my $hint = $extern ? " (external address)" : "";
146 printf ("%-5d %10.2f %s$hint\n", $ref->{count}, $ref->{spamlevel}, $ref->{pmail});
147 }
148
149 $sth->finish();
150
151 return undef;
152 }});
153
9a56f771
DM
154__PACKAGE__->register_method ({
155 name => 'send',
156 path => 'send',
157 method => 'POST',
158 description => "Generate and send spam report emails.",
159 parameters => {
160 additionalProperties => 0,
161 properties => {
6dfa0834 162 receiver => get_standard_option('pmg-email-address', {
9a56f771 163 description => "Generate report for a single email address. If not specified, generate reports for all users.",
252b770d 164 optional => 1,
6dfa0834 165 }),
9a56f771
DM
166 timespan => {
167 description => "Select time span.",
168 type => 'string',
169 enum => ['today', 'yesterday', 'week'],
170 default => 'today',
171 optional => 1,
172 },
173 style => {
252b770d 174 description => "Spam report style. Default value is read from spam quarantine configuration.",
9a56f771 175 type => 'string',
252b770d 176 enum => ['short', 'verbose', 'custom'],
9a56f771 177 optional => 1,
9a56f771 178 },
6dfa0834 179 redirect => get_standard_option('pmg-email-address', {
9a56f771 180 description => "Redirect spam report email to this address.",
9a56f771 181 optional => 1,
6dfa0834 182 }),
f73a1872
DM
183 debug => {
184 description => "Debug mode. Print raw email to stdout instead of sending them.",
185 type => 'boolean',
186 optional => 1,
187 default => 0,
188 }
9a56f771
DM
189 },
190 },
191 returns => { type => 'null'},
192 code => sub {
193 my ($param) = @_;
194
195 my $cinfo = PMG::ClusterConfig->new();
196 my $role = $cinfo->{local}->{type} // '-';
197
198 if (!(($role eq '-') || ($role eq 'master'))) {
7a7e03e6
SI
199 warn "local node is not master - not sending spam report\n";
200 return;
252b770d 201 }
9a56f771
DM
202
203 my $cfg = PMG::Config->new();
204
f73a1872 205 my $reportstyle = $param->{style} // $cfg->get('spamquar', 'reportstyle');
9a56f771 206
35cca388
DM
207 # overwrite report style none when:
208 # - explicit receiver specified
209 # - when debug flag enabled
210 if ($reportstyle eq 'none') {
211 $reportstyle = 'verbose' if $param->{debug} || defined($param->{receiver});
212 }
213
252b770d
DM
214 return if $reportstyle eq 'none'; # do nothing
215
9a56f771
DM
216 my $timespan = $param->{timespan} // 'today';
217
2f7031b7 218 my ($start, $end) = PMG::Utils::lookup_timespan($timespan);
9a56f771
DM
219
220 my $hostname = PVE::INotify::nodename();
252b770d
DM
221
222 my $fqdn = $cfg->get('spamquar', 'hostname') //
9a56f771 223 PVE::Tools::get_fqdn($hostname);
252b770d 224
c333c6e0
DC
225 my $port = $cfg->get('spamquar', 'port') // 8006;
226
227 my $protocol = $cfg->get('spamquar', 'protocol') // 'https';
252b770d 228
afd87d50
SI
229 my $protocol_fqdn_port = "$protocol://$fqdn";
230 if (($protocol eq 'https' && $port != 443) ||
231 ($protocol eq 'http' && $port != 80)) {
232 $protocol_fqdn_port .= ":$port";
233 }
234
ea1b7665
SI
235 my $authmode = $cfg->get ('spamquar', 'authmode') // 'ticket';
236
9a56f771 237 my $global_data = {
c333c6e0 238 protocol => $protocol,
9a56f771
DM
239 port => $port,
240 fqdn => $fqdn,
241 hostname => $hostname,
9a56f771
DM
242 date => strftime("%F", localtime($end - 1)),
243 timespan => $timespan,
244 items => [],
afd87d50 245 protocol_fqdn_port => $protocol_fqdn_port,
ea1b7665 246 authmode => $authmode,
9a56f771
DM
247 };
248
252b770d 249 my $mailfrom = $cfg->get ('spamquar', 'mailfrom') //
9a56f771 250 "Proxmox Mail Gateway <postmaster>";
252b770d 251
9a56f771
DM
252 my $dbh = PMG::DBTools::open_ruledb();
253
254 my $target = $param->{receiver};
255 my $redirect = $param->{redirect};
252b770d 256
1388c118 257 if (defined($redirect) && !defined($target)) {
9a56f771
DM
258 die "can't redirect mails for all users\n";
259 }
252b770d 260
9a56f771 261 my $domains = PVE::INotify::read_file('domains');
567516ae 262 my $domainregex = PMG::Utils::domain_regex([keys %$domains]);
9a56f771 263
252b770d
DM
264 my $template = "spamreport-${reportstyle}.tt";
265 my $found = 0;
266 foreach my $path (@$PMG::Config::tt_include_path) {
267 if (-f "$path/$template") { $found = 1; last; }
268 }
269 if (!$found) {
270 warn "unable to find template '$template' - using default\n";
271 $template = "spamreport-verbose.tt";
9a56f771 272 }
78ee390d 273
9a56f771 274 my $sth = $dbh->prepare(
252b770d
DM
275 "SELECT * FROM CMailStore, CMSReceivers " .
276 "WHERE time >= $start AND time < $end AND " .
9a56f771
DM
277 ($target ? "pmail = ? AND " : '') .
278 "QType = 'S' AND CID = CMailStore_CID AND RID = CMailStore_RID " .
279 "AND Status = 'N' " .
280 "ORDER BY pmail, time, receiver");
252b770d 281
9a56f771 282 if ($target) {
6dfa0834 283 $sth->execute(encode('UTF-8', $target));
9a56f771
DM
284 } else {
285 $sth->execute();
286 }
287
9a56f771
DM
288 my $mailcount = 0;
289 my $creceiver = '';
290 my $data;
78ee390d 291
310daf18
DM
292 my $tt = PMG::Config::get_template_toolkit();
293
9a56f771 294 my $finalize = sub {
252b770d 295
9a56f771 296 my $extern = ($domainregex && $creceiver !~ $domainregex);
252b770d
DM
297 if (!$extern) {
298 $data->{mailcount} = $mailcount;
299 my $sendto = $redirect ? $redirect : $creceiver;
300 PMG::Utils::finalize_report($tt, $template, $data, $mailfrom, $sendto, $param->{debug});
9a56f771
DM
301 }
302 };
252b770d 303
9a56f771 304 while (my $ref = $sth->fetchrow_hashref()) {
6dfa0834
SI
305 my $decoded_pmail = PMG::Utils::try_decode_utf8($ref->{pmail});
306 if ($creceiver ne $decoded_pmail) {
9a56f771
DM
307
308 $finalize->() if $data;
309
310 $data = clone($global_data);
252b770d 311
6dfa0834 312 $creceiver = $decoded_pmail;
9a56f771
DM
313 $mailcount = 0;
314
6dfa0834
SI
315 $data->{pmail} = encode_entities($decoded_pmail);
316 $data->{pmail_raw} = $ref->{pmail};
ea1b7665
SI
317 $data->{managehref} = "$protocol_fqdn_port/quarantine";
318 if ($data->{authmode} ne 'ldap') {
b672f61a 319 $data->{ticket} = PMG::Ticket::assemble_quarantine_ticket($data->{pmail_raw});
ea1b7665
SI
320 my $esc_ticket = uri_escape($data->{ticket});
321 $data->{managehref} .= "?ticket=${esc_ticket}";
322 }
323
9a56f771
DM
324 }
325
252b770d
DM
326 push @{$data->{items}}, get_item_data($data, $ref);
327
9a56f771 328 $mailcount++;
9a56f771
DM
329 }
330
331 $sth->finish();
332
333 $finalize->() if $data;
334
335 if (defined($target) && !$mailcount) {
336 print STDERR "no mails for '$target'\n";
337 }
338
339 return undef;
340 }});
341
342sub find_stale_files {
343 my ($path, $lifetime, $purge) = @_;
344
7205df3e
DM
345 return if ! -d $path;
346
27fbc388
DM
347 my (undef, undef, undef, $mday, $mon, $year) = localtime(time());
348 my $daystart = timelocal(0, 0, 0, $mday, $mon, $year);
349 my $expire = $daystart - $lifetime*86400;
350
351 my $wanted = sub {
352 my $name = $File::Find::name;
353 return if $name !~ m|^($path/.*)$|;
354 $name = $1; # untaint
355 my $stat = stat($name);
356 return if ! -f _;
cd938884 357 return if $stat->mtime >= $expire;
27fbc388
DM
358 if ($purge) {
359 if (unlink($name)) {
360 print "removed: $name\n";
361 }
362 } else {
363 print "$name\n";
364 }
365 };
9a56f771 366
27fbc388 367 find({ wanted => $wanted, no_chdir => 1 }, $path);
9a56f771
DM
368}
369
370sub test_quarantine_files {
371 my ($spamlifetime, $viruslifetime, $purge) = @_;
252b770d
DM
372
373 print STDERR "searching for stale files\n" if !$purge;
9a56f771 374
7205df3e
DM
375 my $spooldir = $PMG::MailQueue::spooldir;
376
377 find_stale_files ("$spooldir/spam", $spamlifetime, $purge);
378 foreach my $dir (<"/var/spool/pmg/cluster/*/spam">) {
379 next if $dir !~ m|^(/var/spool/pmg/cluster/\d+/spam)$|;
380 $dir = $1; # untaint
381 find_stale_files ($dir, $spamlifetime, $purge);
382 }
9a56f771 383
7205df3e
DM
384 find_stale_files ("$spooldir/virus", $viruslifetime, $purge);
385 foreach my $dir (<"/var/spool/pmg/cluster/*/virus">) {
386 next if $dir !~ m|^(/var/spool/pmg/cluster/\d+/virus)$|;
387 $dir = $1; # untaint
388 find_stale_files ($dir, $viruslifetime, $purge);
389 }
9a56f771
DM
390}
391
392__PACKAGE__->register_method ({
393 name => 'purge',
394 path => 'purge',
395 method => 'POST',
396 description => "Cleanup Quarantine database. Remove entries older than configured quarantine lifetime.",
397 parameters => {
398 additionalProperties => 0,
27fbc388
DM
399 properties => {
400 check => {
b66faa68 401 description => "Only search for quarantine files older than configured quarantine lifetime. Just print found files, but do not remove them.",
27fbc388
DM
402 type => 'boolean',
403 optional => 1,
404 default => 0,
405 }
406 }
9a56f771
DM
407 },
408 returns => { type => 'null'},
409 code => sub {
410 my ($param) = @_;
411
412 my $cfg = PMG::Config->new();
413
414 my $spamlifetime = $cfg->get('spamquar', 'lifetime');
415 my $viruslifetime = $cfg->get ('virusquar', 'lifetime');
416
27fbc388 417 my $purge = !$param->{check};
9a56f771 418
27fbc388 419 if ($purge) {
252b770d 420 print STDERR "purging database\n";
9a56f771 421
27fbc388
DM
422 my $dbh = PMG::DBTools::open_ruledb();
423
424 if (my $count = PMG::DBTools::purge_quarantine_database($dbh, 'S', $spamlifetime)) {
252b770d 425 print STDERR "removed $count spam quarantine files\n";
27fbc388 426 }
9a56f771 427
27fbc388 428 if (my $count = PMG::DBTools::purge_quarantine_database($dbh, 'V', $viruslifetime)) {
252b770d 429 print STDERR "removed $count virus quarantine files\n";
27fbc388 430 }
02fea5bc
DC
431
432 if (my $count = PMG::DBTools::purge_quarantine_database($dbh, 'A', $spamlifetime)) {
433 print STDERR "removed $count attachment quarantine files\n";
434 }
9a56f771
DM
435 }
436
27fbc388 437 test_quarantine_files($spamlifetime, $viruslifetime, $purge);
9a56f771
DM
438
439 return undef;
440 }});
441
9a56f771
DM
442
443our $cmddef = {
9a56f771
DM
444 'purge' => [ __PACKAGE__, 'purge', []],
445 'send' => [ __PACKAGE__, 'send', []],
252b770d 446 'status' => [ __PACKAGE__, 'status', []],
9a56f771
DM
447};
448
4491;