diff options
-rw-r--r-- | CHANGES.md | 12 | ||||
-rw-r--r-- | MANIFEST | 3 | ||||
-rw-r--r-- | Makefile.PL | 1 | ||||
-rw-r--r-- | README.md | 84 | ||||
-rw-r--r-- | jwebmail.development.toml | 12 | ||||
-rw-r--r-- | lib/JWebmail.pm | 46 | ||||
-rw-r--r-- | lib/JWebmail/Controller/Webmail.pm | 26 | ||||
-rw-r--r-- | lib/JWebmail/Model/ReadMails/MockJSON.pm | 4 | ||||
-rw-r--r-- | lib/JWebmail/Model/ReadMails/MockMaildir.pm | 46 | ||||
-rw-r--r-- | lib/JWebmail/Model/ReadMails/QMailAuthuser.pm | 82 | ||||
-rw-r--r-- | lib/JWebmail/Model/ReadMails/Role.pm | 31 | ||||
-rw-r--r-- | lib/JWebmail/Plugin/Helper.pm | 44 | ||||
-rwxr-xr-x | script/qmauth.pl | 273 | ||||
-rwxr-xr-x | script/qmauth.py | 349 | ||||
-rw-r--r-- | t/Extract.t | 49 | ||||
-rw-r--r-- | t/Helper.t | 64 | ||||
-rw-r--r-- | t/Webmail.t | 8 | ||||
-rw-r--r-- | templates/headers/_display_bot_nav.html.ep | 18 | ||||
-rw-r--r-- | templates/headers/_display_folders.html.ep | 8 | ||||
-rw-r--r-- | templates/headers/_display_headers.html.ep | 15 | ||||
-rw-r--r-- | templates/headers/_pagination1.html.ep | 10 | ||||
-rw-r--r-- | templates/headers/_pagination2.html.ep | 10 | ||||
-rw-r--r-- | templates/webmail/readmail.html.ep | 43 |
23 files changed, 873 insertions, 365 deletions
@@ -134,7 +134,7 @@ Current v1.1.0 - [ ] moving mails to other folders - [ ] creating new folders - [ ] backend -- [ ] specify protocol for backend interaction +- [ ] specify protocol for backend interaction (RPC IDL) - [ ] choose tool for validation - [ ] cleanup README - [ ] improve about page @@ -151,15 +151,15 @@ Current v1.1.0 - [ ] advance toml config plugin - [ ] add template support, maybe - [ ] add config validation - -- [x] compute hmac on the client side - - [x] better handling on form - - [ ] better random numbers - [ ] improve i18n - [ ] add localization of dates and time - [x] refactor I18N plugin to allow independent translate provider - [x] extend matcher dynamic with a role +- [x] compute hmac on the client side + - [x] better handling on form + - [ ] better random numbers + Future ------ - [ ] INV: wrong subject being shown @@ -198,3 +198,5 @@ Future - [ ] address book support - [ ] add links on email addresses in header : click = add into address book - [ ] read and write s3d only once per request ($c->on('finish')) +- [ ] classless CSS framework +- [ ] dark and light mode @@ -14,6 +14,9 @@ lib/JWebmail/Model/WriteMails.pm lib/JWebmail/Plugin/Helper.pm lib/JWebmail/Plugin/TOMLConfig.pm lib/JWebmail/Plugin/I18N2.pm +lib/JWebmail/Plugin/I18N2/Role.pm +lib/JWebmail/Plugin/I18N2/INI.pm +lib/JWebmail/Plugin/I18N2/Maketext.pm lib/JWebmail/Plugin/ServerSideSessionData.pm t/Webmail.t diff --git a/Makefile.PL b/Makefile.PL index 312fb5a..4209f3b 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -17,6 +17,7 @@ WriteMakefile( Class::Method::Modifiers => '2.13', TOML::Tiny => '0.15', namespace::clean => '0.27', + MIME::Words => '5.510', }, EXE_FILES => ['script/jwebmail', 'script/qmauth.pl'], test => {TESTS => 't/*.t'}, @@ -143,3 +143,87 @@ Process overview The Webserver acts as a proxy to the Application Server. The Extractor is a stateless process that reads mails from a source. + +## Extractor Interface Definition + +Stateless Service - `net.fehcom.JWebmail.QMAuth.Maildir.repository` v0.1.0 + + struct MailAddress { + display_name: unicode_text, + address: unicode_text, + } + + struct TopHead { + byte_size: uint64, + unread: bool, + date_received: iso8601, + message_handle: unicode_text, + head: Head, + } + + struct Head { + date: iso8601, + + from: Array[MailAddress], + sender: Array[MailAddress], + reply_to: Array[MailAddress], + + to: Array[MailAddress], + cc: Array[MailAddress], + bcc: Array[MailAddress], + + subject: unicode_text, + comments: unicode_text, + keywords: unicode_text, + + mime: MIMEHeader, + } + + sturct MIMEHeader { + content_maintype: unicode_text, + content_subtype: unicode_text, + content_disposition: unicode_text, + filename: unicode_text, + } + + struct MIMEPart { + head: MIMEHeader, + body: Body, + } + + enum Body { + discrete(unicode_text), + multipart{ + preamble: unicode_text, + parts: Array[MIMEPart], + epilogue: unicode_text, + }, + message(Message), + } + + // body is either a string (discrete), an array (multipart) or object (message) + // depending on the content type + struct Message { + head: Head, + body: Body, + } + + struct CountResult { + byte_size: uint64, + total_mails: uint32, + unread_mails: uint32, + } + + fn __start__(maildir_directory: filepath, os_user: unicode_text, mail_user: unicode_text) + + fn list(subfolder: unicode_text, start: uint32, end: uint32, sort_by: unicode_text) -> Array[TopHead] + + fn count(folder: unicode_text) -> CountResult + + fn read(folder: unicode_text, mid: unicode_text) -> Message + + fn folders() -> Array[unicode_text] + + fn move(mid: unicode_text, from_folder: unicode_text, to_folder: unicode_text) + + fn search(pattern: unicode_text, folder: unicode_text) -> Array[Message] diff --git a/jwebmail.development.toml b/jwebmail.development.toml index 7f58015..c4aa39c 100644 --- a/jwebmail.development.toml +++ b/jwebmail.development.toml @@ -1,3 +1,5 @@ +logpath = "log/" + [defaults] scriptadmin = "me@example.com" # for complaints / support @@ -6,15 +8,13 @@ default_language = "en" directory = "lib/JWebmail/I18N" # languages = ["en", "de"] -[model.read.driver.devel.json] -[model.read.driver.devel.maildir] +[model.read.devel] +driver = "JWebmail::Model::ReadMails::MockMaildir" +#driver = "JWebmail::Model::ReadMails::MockJSON" [model.write] #sendmail = "/usr/sbin/sendmail" - -[development] -read_mock = "JWebmail::Model::ReadMails::MockJSON" # JWebmail::Model::ReadMails::MockMaildir -block_writes = 1 +devel.block_writes = 1 [session] # secure sesssion [none, cram, s3d] diff --git a/lib/JWebmail.pm b/lib/JWebmail.pm index 1c66001..3abc78d 100644 --- a/lib/JWebmail.pm +++ b/lib/JWebmail.pm @@ -1,4 +1,4 @@ -package JWebmail v1.2.0; +package JWebmail v1.2.1; use Mojo::Base Mojolicious; @@ -10,27 +10,18 @@ use JWebmail::Model::ReadMails::QMailAuthuser; use JWebmail::Model::WriteMails; -sub nest { -} sub validateConf { my $self = shift; my $conf = $self->config; - my $v = $self->validator->validation->input($conf); - $v->optional('secret', 'not_empty'); + exists $conf->{session}{secure} or die; + grep(sub { $_ eq $conf->{session}{secure} }, qw(none cram s3d)) > 0 or die; - $v->optional('i18n'); - $v->required('session')->required('secure')->in(qw(none cram s3d)); - $v->required('defaults')->required('scriptadmin')->like(qr/@/); - my $dev = $v->optional('development'); - $dev->optional('read_mock', 'not_empty'); - $dev->optional('block_writes')->in(0, 1); + exists $conf->{defaults}{scriptadmin} or die; + $conf->{defaults}{scriptadmin} =~ /@/ or die; - for ($v->failed->@*) { - say "reasons for $_: " , $v->error($_)->@*; - } - $v->is_valid; + return 1; } @@ -39,15 +30,19 @@ sub startup { $self->moniker('jwebmail'); - $self->log->path($self->home->child('log', $self->mode . '.log')); - # load plugins push @{$self->plugins->namespaces}, 'JWebmail::Plugin'; $self->plugin('TOMLConfig'); - #die unless $self->validateConf; + $self->validateConf; - $self->plugin('ServerSideSessionData'); + if (my $logpath = $self->config('logpath')) { + $self->log->path($logpath . '/' . $self->mode . '.log'); + } + + if (fc $self->config->{session}{secure} eq fc 's3d') { + $self->plugin('ServerSideSessionData'); + } $self->plugin('Helper'); my $i18n_route = $self->plugin('I18N2', $self->config('i18n')); @@ -57,21 +52,22 @@ sub startup { # initialize models my $read_mails = do { if ($self->mode eq 'development') { - my $cls = $self->config->{development}{read_mock}; + my $cls = $self->config->{model}{read}{devel}{driver}; eval { load $cls; 1 } || die "Issue for module $cls with: $@"; - $cls->new(); + $cls->new(($self->config->{model}{read}{devel} // {})->%*) } else { JWebmail::Model::ReadMails::QMailAuthuser->new( - logfile => $self->home->child('log', 'extract.log'), - ); + ($self->config->{model}{read}{prod} // {})->%* + ) } }; die "given class @{[ ref $read_mails ]} does not ReadMails" unless $read_mails->DOES('JWebmail::Model::ReadMails::Role'); $self->helper(users => sub { $read_mails }); $self->helper(send_mail => sub { my ($c, $mail) = @_; JWebmail::Model::WriteMails::sendmail($mail) }); - $JWebmail::Model::WriteMails::Block_Writes = 1 if $self->mode eq 'development'; + $JWebmail::Model::WriteMails::Block_Writes = 1 + if $self->mode eq 'development' && $self->config->{model}{write}{devel}{block_writes}; $self->defaults(version => __PACKAGE__->VERSION); @@ -98,7 +94,7 @@ sub route { $a->get('/home/*folder')->to('Webmail#displayheaders', folder => '')->name('displayheaders'); $a->get('/read/#id' => 'read')->to('Webmail#readmail'); $a->get('/write')->to('Webmail#writemail'); - $a->post('/write' => 'send')-> to('Webmail#sendmail'); + $a->post('/write' => 'send')->to('Webmail#sendmail'); $a->post('/move')->to('Webmail#move'); } diff --git a/lib/JWebmail/Controller/Webmail.pm b/lib/JWebmail/Controller/Webmail.pm index b094a43..2f71021 100644 --- a/lib/JWebmail/Controller/Webmail.pm +++ b/lib/JWebmail/Controller/Webmail.pm @@ -7,7 +7,7 @@ use List::Util 'first'; use Mojolicious::Types; use constant { - S_USER => 'user', # Key for user name in active session + S_USER => 'user', # Key for user name in active session ST_AUTH => 'auth', }; @@ -134,7 +134,7 @@ sub displayheaders { my $folders = _time { $self->users->folders($auth) } $self, 'user folders'; - unless ( $self->stash('folder') ~~ $folders ) { + unless ( !$self->stash('folder') || $self->stash('folder') ~~ $folders ) { $self->render(template => 'error', status => 404, error => $self->l('no_folder'), @@ -153,9 +153,9 @@ sub displayheaders { return; } - my ($total_byte_size, $cnt, $new) = _time { $self->users->count($auth, $self->stash('folder')) } $self, 'user count'; + my $count = _time { $self->users->count($auth, $self->stash('folder')) } $self, 'user count'; - my ($start, $end) = $self->paginate($cnt); + my ($start, $end) = $self->paginate($count->{total_mails}); $self->timing->begin('user_headers'); my $headers = do { @@ -178,10 +178,10 @@ sub displayheaders { $self->app->log->debug(sprintf("Reading user headers took %fs", $elapsed)); $self->stash( - msgs => $headers, - mail_folders => $folders, - total_size => $total_byte_size, - total_new_mails => $new, + msgs => $headers, + mail_folders => $folders, + total_size => $count->{byte_size}, + total_new_mails => $count->{unread_mails}, ); } @@ -194,7 +194,7 @@ sub readmail { my $auth = $self->stash(ST_AUTH); my $mail; - my $ok = eval { $mail = $self->users->show($auth, $mid); 1 }; + my $ok = eval { $mail = $self->users->show($auth, '', $mid); 1 }; if (!$ok) { my $err = $@; if ($err =~ m/unkown mail-id|no such message/) { @@ -210,11 +210,11 @@ sub readmail { return if $v->has_error; if ($type) { - if ($mail->{head}{content_type} =~ '^multipart/') { - my $content = first {$_->{head}{content_type} =~ $type} @{ $mail->{body} }; + if ($mail->{head}{mime}{content_maintype} eq 'multipart') { + my $content = first {$_->{head}{mime}{content_subtype} eq $type} @{ $mail->{body} }; $self->render(text => $content->{body}); } - elsif ($mail->{head}{content_type} =~ $type) { + elsif ($mail->{head}{mime}{content_subtype} eq $type) { $self->render(text => $mail->{body}) ; } else { @@ -297,7 +297,7 @@ sub move { no warnings 'experimental::smartmatch'; die "$folder not valid" unless $folder ~~ $folders; - $self->users->move($auth, $_, $folder) for @$mm; + $self->users->move($auth, $_, '', $folder) for @$mm; $self->flash(message => $self->l('succ_move')); $self->res->code(303); diff --git a/lib/JWebmail/Model/ReadMails/MockJSON.pm b/lib/JWebmail/Model/ReadMails/MockJSON.pm index b90a630..6b3b6d2 100644 --- a/lib/JWebmail/Model/ReadMails/MockJSON.pm +++ b/lib/JWebmail/Model/ReadMails/MockJSON.pm @@ -14,8 +14,8 @@ use Role::Tiny::With; use namespace::clean; use constant { - VALID_USER => 'mockjson@example.com', - VALID_PW => 'vwxyz', + VALID_USER => 'mockjson@example.org', + VALID_PW => '12345', }; with 'JWebmail::Model::ReadMails::Role'; diff --git a/lib/JWebmail/Model/ReadMails/MockMaildir.pm b/lib/JWebmail/Model/ReadMails/MockMaildir.pm index 2df4fa9..9b1bb29 100644 --- a/lib/JWebmail/Model/ReadMails/MockMaildir.pm +++ b/lib/JWebmail/Model/ReadMails/MockMaildir.pm @@ -1,38 +1,59 @@ package JWebmail::Model::ReadMails::MockMaildir; -use Mojo::Base JWebmail::Model::ReadMails::QMailAuthuser; +use Mojo::Base 'JWebmail::Model::ReadMails::QMailAuthuser'; use Mojo::JSON 'decode_json'; +use Digest::HMAC_MD5 'hmac_md5_hex'; + + use constant { - VALID_USER => 'me@mockmaildir.com', + VALID_USER => 'mockmaildir@example.org', VALID_PW => '12345', }; - has user => sub { $ENV{USER} }; has maildir => 't/'; has extractor => 'perl'; - our %EXTRACTORS = ( - perl => 'perl script/qmauth.pl', - rust => 'extract/target/debug/jwebmail-extract', + perl => 'perl script/qmauth.pl', + python => 'python script/qmauth.py', + rust => 'extract/target/debug/jwebmail-extract', ); +sub new { + my $cls = shift; + my %args = @_ == 1 ? %$_[0] : @_; + + my $self = bless {%args}, ref $cls || $cls; + $self->user; + $self->maildir; + + $self->next::method(prog => $EXTRACTORS{$self->extractor}); + return $self; +} + + sub verify_user { my $self = shift; my $auth = shift; - return $auth->{user} eq VALID_USER && $auth->{password} eq VALID_PW; + my $passwd = $auth->{password}->show_password; + + if ($auth->{challenge}) { + return $auth->{user} eq VALID_USER && + $passwd eq hmac_md5_hex($auth->{challenge}, VALID_PW); + } + else { + return $auth->{user} eq VALID_USER && $passwd eq VALID_PW; + } } sub build_and_run { my $self = shift; - my $auth = shift; - my $mode = shift; - my $args = shift; + my ($auth, $mode, $args) = @_; my $mail_user = 'maildir'; my $exec = $EXTRACTORS{$self->extractor} . ' ' . join(' ', map { my $x = s/(['\\])/\\$1/gr; "'$x'" } ($self->maildir, $self->user, $mail_user, $mode, @$args)); @@ -51,10 +72,11 @@ sub build_and_run { if (my $err = $@) { $resp = {error => "decoding error '$err'"}; $rc ||= 1; }; } elsif ($rc) { - $resp = {error => "qmail-authuser returned code: $rc"}; + $resp = {error => "qmauth returned code: $rc"}; } - die "error $resp" if $rc; + local $" = ', '; + die "error @{[%$resp]}" if $rc; return $resp; } diff --git a/lib/JWebmail/Model/ReadMails/QMailAuthuser.pm b/lib/JWebmail/Model/ReadMails/QMailAuthuser.pm index 39d8ab6..956c137 100644 --- a/lib/JWebmail/Model/ReadMails/QMailAuthuser.pm +++ b/lib/JWebmail/Model/ReadMails/QMailAuthuser.pm @@ -8,8 +8,8 @@ use File::Basename 'fileparse'; use IPC::Open2; use JSON::PP 'decode_json'; use Params::Check 'check'; -use Scalar::Util 'blessed'; use Role::Tiny::With; +use Scalar::Util 'blessed'; use namespace::clean; with 'JWebmail::Model::ReadMails::Role'; @@ -51,7 +51,10 @@ package JWebmail::Model::ReadMails::QMailAuthuser::Error { my $cls = shift; my $msg = shift; - die $cls->new($msg, @_)->_trace; + my $self = $cls->new($msg, @_); + $self->_trace; + + die $self; } # taken from Mojo::Exception @@ -70,12 +73,12 @@ package JWebmail::Model::ReadMails::QMailAuthuser::Error { my $QMailAuthuserCheck = { - user => {defined => 1, required => 1}, - maildir => {defined => 1, required => 1}, - prog => {defined => 1, required => 1}, - prefix => {defined => 1, default => ''}, - qmail_dir => {defined => 1, default => '/var/qmail/'}, - logfile => {defined => 1, default => '/dev/null'}, + user => {required => 1}, + maildir => {required => 1}, + prog => {required => 1}, + prefix => {default => ''}, + qmail_dir => {default => '/var/qmail/'}, + logfile => {default => '/dev/null'}, }; sub new { @@ -86,8 +89,12 @@ sub new { $self = {%$cls, %$self}; $cls = $pkg; } - $self = check($QMailAuthuserCheck, $self, 1) || die; - return bless $self, $cls; + local $Params::Check::ALLOW_UNKNOWN = 1; + local $Params::Check::ONLY_ALLOW_DEFINED = 1; + local $Params::Check::WARNINGS_FATAL = 1; + my $s = check($QMailAuthuserCheck, $self) + or die __PACKAGE__ . " creation failed!"; + return bless $s, $cls; } @@ -95,8 +102,8 @@ sub verify_user { my $self = shift; my $auth = shift; - eval { $self->build_and_run($auth, 'auth'); 1 } - or do { + return eval { $self->build_and_run($auth, 'auth'); 1 } + || do { my $e = $@; my $rc = eval { $e->data->{return_code} }; if ($rc == 1) { @@ -115,22 +122,21 @@ sub read_headers_for { my %h = @_; my ($folder, $start, $end, $sort) = @h{qw(folder start end sort)}; - return $self->build_and_run($auth, 'list', [$start, $end, $sort, $folder]); + return $self->build_and_run($auth, 'list', [$folder, $start, $end, $sort]); } sub count { my $self = shift; my ($auth, $folder) = @_; - my $resp = $self->build_and_run($auth, 'count', [$folder]); - return ($resp->{size}, $resp->{count}, $resp->{new}); + return $self->build_and_run($auth, 'count', [$folder]); } sub show { my $self = shift; - my ($auth, $mid) = @_; + my ($auth, $folder, $mid) = @_; - return $self->build_and_run($auth, 'read-mail', [$mid]); + return $self->build_and_run($auth, 'read', [$folder, $mid]); } sub search { @@ -144,23 +150,23 @@ sub folders { my $self = shift; my ($auth) = @_; - return $self->build_and_run($auth, 'folders'); + my $res = $self->build_and_run($auth, 'folders'); + unshift @$res, '' if ref $res eq 'ARRAY'; + return $res; } sub move { my $self = shift; - my ($auth, $mid, $folder) = @_; + my ($auth, $mid, $from_f, $to_f) = @_; - my $_resp = $self->build_and_run($auth, 'move', [$mid, $folder]); + my $_resp = $self->build_and_run($auth, 'move', [$mid, $from_f, $to_f]); return 1; } sub build_arg { my $self = shift; - my $user_mail_addr = shift; - my $mode = shift; - my $args = shift || []; + my ($user_mail_addr, $mode, $args) = @_; return $self->{qmail_dir} . "/bin/qmail-authuser true 3<&0" if $mode eq 'auth'; @@ -169,15 +175,14 @@ sub build_arg { return $self->{qmail_dir}.'/bin/qmail-authuser' . $self->{prefix} . ' ' - . join(' ', map { my $x = s/(['\\])/\\$1/gr; "'$x'" } ($self->{prog}, $self->{maildir}, $self->{user}, $user_name, $mode, @$args)) + . join(' ', map { my $x = s/(['\\])/\\$1/gr; "'$x'" } ($self->{prog}, $self->{maildir}, $self->{user}, $user_name, $mode, @{$args || []})) . ' 3<&0' . ' 2>>'.$self->{logfile}; } sub execute { my $_self = shift; - my $auth = shift; - my $exec = shift; + my ($auth, $exec) = @_; my $pid = open2(my $reader, my $writer, $exec) or die 'failed to create subprocess'; @@ -190,18 +195,28 @@ sub execute { binmode $reader, ':encoding(UTF-8)'; my $input = <$reader>; - close $reader # waits for the child to finish + close $reader or die 'closing read pipe failed'; + waitpid $pid, 0; my $rc = $? >> 8; my $resp; if ($rc == 3 || $rc == 0) { eval { $resp = decode_json $input; 1 } - or $resp = {info => "error decoding response", response => $input, cause => $@, return_code => $rc}; + or $resp = { + info => "error decoding response", + response => $input, + cause => $@, + return_code => $rc, + }; } elsif ($rc) { - $resp = {info => "got unsuccessful return code by qmail-authuser", return_code => $rc, response => $input}; + $resp = { + info => "got unsuccessful return code by qmail-authuser", + return_code => $rc, + response => $input, + }; } return ($resp, $rc); @@ -209,15 +224,14 @@ sub execute { sub build_and_run { my $self = shift; - my $auth = shift; - my $mode = shift; - my $args = shift; + my ($auth, $mode, $args) = @_; my $exec = $self->build_arg($auth->{user}, $mode, $args); my ($resp, $rc) = $self->execute($auth, $exec); if ($rc) { - JWebmail::Model::ReadMails::QMailAuthuser::Error->throw("qmail-authuser connection error", $resp); + JWebmail::Model::ReadMails::QMailAuthuser::Error->throw( + "qmail-authuser connection error", $resp); } return $resp; } @@ -291,5 +305,3 @@ Challenge when using cram =head1 SEE ALSO L<JWebmail::Model::ReadMails>, L<JWebmail::Model::Driver::QMailAuthuser::Extract> - -=cut diff --git a/lib/JWebmail/Model/ReadMails/Role.pm b/lib/JWebmail/Model/ReadMails/Role.pm index 1f4390b..d6fa1e5 100644 --- a/lib/JWebmail/Model/ReadMails/Role.pm +++ b/lib/JWebmail/Model/ReadMails/Role.pm @@ -1,6 +1,6 @@ package JWebmail::Model::ReadMails::Role; -use Params::Check 'check'; +use Params::Check qw(check last_error); use Mojo::Base -role; # load after imports @@ -18,13 +18,15 @@ package JWebmail::Model::ReadMails::Role::Shadow { sub Auth { shift; state $AuthCheck = { - user => {defined => 1, required => 1}, - password => {defined => 1, required => 1}, + user => {required => 1, defined => 1}, + password => {required => 1, defined => 1}, challenge => {}, }; my $self = @_ == 1 ? $_[0] : {@_}; - my $res = check($AuthCheck, $self, 0) || die Params::Check::last_error; + local $Params::Check::WARNINGS_FATAL = 1; + my $res = check($AuthCheck, $self, 0) + or die 'Auth creation failed! ' . last_error; $res->{password} = JWebmail::Model::ReadMails::Role::Shadow->new($res->{password}); return $res; } @@ -36,14 +38,14 @@ my @methods = ( # ^ throws exception # ^type throws exception of type # Read operations - 'verify_user', # auth:Auth -> :truthy - 'read_headers_for', # auth:Auth, *folder='', *start=0, *end=24, *sort='date' -> ^ :hashref 'count', # auth:Auth, folder -> ^ size:int count:int new:int - 'show', # auth:Auth, mid -> ^ :hashref - 'search', # auth:Auth, pattern, folder -> ^ :hashref 'folders', # auth:Auth -> ^ :arrayref + 'verify_user', # auth:Auth -> :truthy # Write operations 'move', # auth:Auth, mid, folder -> ^ 1 + 'read_headers_for', # auth:Auth, *folder='', *start=0, *end=24, *sort='date' -> ^ :hashref + 'search', # auth:Auth, pattern, folder -> ^ :hashref + 'show', # auth:Auth, mid -> ^ :hashref ); requires(@methods); @@ -66,13 +68,14 @@ around read_headers_for => sub { my $args = {@_}; state $ArgsCheck = { - start => {default => 0}, - end => {default => 24}, - sort => {default => 'date'}, + start => {required => 1}, + end => {required => 1}, + sort => {default => ''}, folder => {default => ''}, }; - $orig->($self, $auth, %{ check($ArgsCheck, $args, 1) }) + local $Params::Check::ONLY_ALLOW_DEFINED = 1; + $orig->($self, $auth, %{ check($ArgsCheck, $args, 0) or die last_error }) }; @@ -107,7 +110,7 @@ Provides bundeled information on a subset of mails of a mailbox. Can be sorted and of varying size. Arguments: - start..end inclusive 0 based range + start..end half open 0 based range =head2 count @@ -150,5 +153,3 @@ Optinal challange for when you use cram authentication. =head1 SEE ALSO L<JWebmail::Model::ReadMails::QMailAuthuser>, L<JWebmail::Model::ReadMails::Mock>, L<JWebmail> - -=cut diff --git a/lib/JWebmail/Plugin/Helper.pm b/lib/JWebmail/Plugin/Helper.pm index ad5c8ad..c00ef0e 100644 --- a/lib/JWebmail/Plugin/Helper.pm +++ b/lib/JWebmail/Plugin/Helper.pm @@ -9,7 +9,6 @@ use POSIX qw(floor round log ceil); use Mojo::Util qw(encode decode b64_encode b64_decode xml_escape); use constant TRUE_RANDOM => eval { require Crypt::URandom; Crypt::URandom->import('urandom'); 1 }; -use constant HMAC_MD5 => eval { require Digest::HMAC_MD5; Digest::HMAC_MD5->import('hmac_md5'); 1 }; ### filter and checks for mojo validator @@ -160,8 +159,6 @@ sub session_passwd { my ($c, $passwd, $challenge) = @_; my $secAlg = $c->config->{session}{secure}; - die "you need to install Digest::HMAC_MD5 for cram to work" - if !HMAC_MD5 && $secAlg eq 'cram'; warn_crypt($c); if (defined $passwd) { # set @@ -234,11 +231,9 @@ sub _paginate { my %args = @_; my $first_item = $args{first_item}; - my $page_size = $args{page_size} || 1; + my $page_size = $args{page_size}; my $total_items = $args{total_items}; - my $first_item1 = $total_items ? $first_item+1 : 0; - my $current_page = ceil($first_item/$page_size); my $total_pages = ceil($total_items/$page_size); @@ -246,23 +241,29 @@ sub _paginate { my $page_ = shift; return [0, 0] unless $total_items; $page_ = _clamp(0, $page_, $total_pages-1); - [_clamp(1, $page_*$page_size + 1, $total_items), _clamp(1, ($page_+1)*$page_size, $total_items)] + [_clamp(0, $page_*$page_size, $total_items-1), _clamp(0, ($page_+1)*$page_size, $total_items)] }; - return ( - first_item => $first_item1, - last_item => _clamp($first_item1, $first_item + $page_size, $total_items), + my %ret = ( total_items => $total_items, page_size => $page_size, total_pages => $total_pages, - current_page => $current_page + 1, + current_page => $current_page, first_page => $page->(0), prev_page => $page->($current_page-1), + this_page => $page->($current_page), next_page => $page->($current_page+1), last_page => $page->($total_pages-1), ); + + if ($total_items) { + $ret{first_item} = $first_item; + $ret{last_item} = _clamp($first_item, $first_item+$page_size-1, $total_items-1); + } + + return %ret; } sub paginate { @@ -274,9 +275,13 @@ sub paginate { my $psize = $v->optional('page_size')->num(1, undef)->param // 50; $start = _clamp(0, $start, max($count-1, 0)); - my $end = _clamp($start, $start+$psize-1, max($count-1, 0)); + my $end = _clamp($start, $start+$psize, max($count, 0)); - $c->stash(_paginate(first_item => $start, page_size => $psize, total_items => $count)); + $c->stash(_paginate( + first_item => int($start/$psize)*$psize, + page_size => $psize, + total_items => $count, + )); return $start, $end; } @@ -435,16 +440,21 @@ Currently the following modes are supported: =item none -password is plainly stored in session cookie +The password is plainly stored in session cookie. +The cookie is stored on the client side and send with every request. =item cram -challenge response authentication mechanism uses the C<< $app->secret->[0] >> as nonce. -This is optional if Digest::HMAC_MD5 is installed. +A nonce is send to the client and the cram_md5 is generated there via js +and crypto-js. +This is vulnurable to replay attacks as the nonce is not invalidated ever. =item s3d -data is stored on the server. Additionally the password is encrypted by an one-time-pad that is stored in the user cookie. +The password is stored on the server. Additionally the password is encrypted +by an one-time-pad that is stored in the users cookie. +This is vulnurable to replay attacks during an active session. +On log-in it is transfered plainly. =back diff --git a/script/qmauth.pl b/script/qmauth.pl index 3ecadef..000eaa0 100755 --- a/script/qmauth.pl +++ b/script/qmauth.pl @@ -5,54 +5,59 @@ use v5.18; use warnings; use utf8; -use POSIX (); -use JSON::PP; use Carp; -use List::Util 'min'; use Encode v2.88 'decode'; +use JSON::PP; +use List::Util 'min'; +use POSIX 'setuid'; #use open IO => ':encoding(UTF-8)', ':std'; -no warnings 'experimental::smartmatch'; -use Mail::Box::Manager; +use Mail::Box::Maildir; -use constant ROOT_MAILDIR => '.'; + +package JWebmail::QMAuth::Message::Head::Complete { + use parent 'Mail::Message::Head::Complete'; + + use File::Basename; + + sub createMessageId { + my $self = shift; + + my ($mid) = scalar(fileparse($self->message->filename)) =~ /(.+):/; + return $mid || $self->SUPER::createMessageId; + } +} sub main { - my ($maildir) = shift(@ARGV) =~ m/(.*)/; - my ($su) = shift(@ARGV) =~ m/(.*)/; - my ($user) = shift(@ARGV) =~ m/([[:alpha:]]+)/; - my $mode = shift @ARGV; _ok($mode =~ m/([[:alpha:]-]{1,20})/); - my @args = @ARGV; + my ($maildir, $su, $user, $mode, @args) = @ARGV; delete $ENV{PATH}; my $netfehcom_uid = getpwnam($su); - #$> = $netfehcom_uid; die "won't stay as root" if $netfehcom_uid == 0; - POSIX::setuid($netfehcom_uid); + setuid($netfehcom_uid); if ($!) { warn 'error setting uid'; exit(1); } - my $folder = Mail::Box::Manager->new->open( - folder => "$maildir/$user/", - type => 'maildir', - access => 'rw', + my $folder = Mail::Box::Maildir->new( + folder => "$maildir/$user/", + type => 'maildir', + access => 'rw', + head_type => 'JWebmail::QMAuth::Message::Head::Complete', ); my $reply = do { - given ($mode) { - when('list') { list($folder, @args) } - when('read-mail') { read_mail($folder, @args) } - when('count') { count_messages($folder, @args) } - when('search') { search($folder, @args) } - when('folders') { folders($folder, @args) } - when('move') { move($folder, @args) } - default { {error => 'unkown mode', mode => $mode} } - } + if ($mode eq 'list') { list($folder, @args) } + elsif ($mode eq 'read') { read_mail($folder, @args) } + elsif ($mode eq 'count') { count_messages($folder, @args) } + elsif ($mode eq 'search') { search($folder, @args) } + elsif ($mode eq 'folders') { folders($folder, @args) } + elsif ($mode eq 'move') { move($folder, @args) } + else { {error => 'unknown mode', mode => $mode} } }; $folder->close; @@ -64,22 +69,26 @@ sub main { sub _sort_mails { - my $sort = shift // ''; - my $reverse = 1; + my ($sort) = @_; - if ($sort =~ m/^!/) { - $reverse = -1; + my $reverse = ''; + if ($sort =~ /^!/) { + $reverse = 1; $sort = substr $sort, 1; } - given ($sort) { - when ('date') { return sub { ($a->timestamp <=> $b->timestamp) * $reverse } } - when ('sender') { return sub { ($a->from->[0] cmp $b->from->[0]) * $reverse } } - when ('subject') { return sub { ($a->subject cmp $b->subject) * $reverse } } - when ('size') { return sub { ($a->size <=> $b->size) * $reverse } } - when ('') { return sub { ($a->timestamp <=> $b->timestamp) * $reverse } } - default { warn "unkown sort-verb '$sort'"; return sub { ($a->timestamp <=> $b->timestamp) * $reverse } } - } + my $sortsub = do { + if ($sort eq 'date') { sub { $a->timestamp <=> $b->timestamp } } + elsif ($sort eq 'sender') { sub { $a->from->[0] cmp $b->from->[0] } } + elsif ($sort eq 'subject') { sub { $a->subject cmp $b->subject } } + elsif ($sort eq 'size') { sub { $a->size <=> $b->size } } + elsif ($sort eq '') { sub { $a->timestamp <=> $b->timestamp } } + else { + warn "unknown sort-verb '$sort'"; + sub { $a->timestamp <=> $b->timestamp } + } + }; + return $reverse ? sub { $sortsub->() * -1 } : $sortsub; } @@ -91,61 +100,79 @@ sub _ok { } +sub _get_mime_head_info { + my ($msg) = @_; + + return { + content_maintype => $msg->body->mimeType->mediaType, + content_subtype => $msg->body->mimeType->subType, + content_disposition => ''.$msg->body->disposition, + filename => $msg->body->dispositionFilename, + }; +} + + +sub _get_head_info { + my ($msg) = @_; + + return { + date => _iso8601_utc($msg->timestamp), + + from => _addresses($msg->from), + sender => _addresses($msg->sender), + + to => _addresses($msg->to), + cc => _addresses($msg->cc), + bcc => _addresses($msg->bcc), + + subject => decode('MIME-Header', $msg->subject), + comments => $msg->get('comments'), + keywords => $msg->get('keywords'), + + mime => _get_mime_head_info($msg), + }; +} + + sub list { - my ($f, $start, $end, $sortby, $folder) = @_; - $folder = ".$folder"; + my ($f, $folder, $start, $end, $sortby) = @_; - _ok($start =~ m/^\d+$/); - _ok($end =~ m/^\d+$/); + _ok($start =~ /^\d+$/aa); + _ok($end =~ /^\d+$/aa); _ok(0 <= $start && $start <= $end); - _ok($sortby =~ m/^(!?\w+|\w*)$/n); - _ok($folder ~~ [$f->listSubFolders, ROOT_MAILDIR]); + _ok($sortby =~ /^(?:!?\w+|)$/aa); + _ok(!$folder || grep { $_ eq ".$folder" } $f->listSubFolders); - $f = $f->openSubFolder($folder) if $folder ne ROOT_MAILDIR; + $f = $f->openSubFolder(".$folder") if $folder; return [] if $start == $end; my $sref = _sort_mails($sortby); my @msgs = $f->messages; @msgs = sort { &$sref } @msgs; - @msgs = @msgs[$start..min($#msgs, $end)]; - - my @msgs2; - - for my $msg (@msgs) { - my $msg2 = { - mid => $msg->messageId, - size => $msg->size, - new => $msg->label('seen'), - head => { - subject => decode('MIME-Header', $msg->subject), - from => _addresses($msg->from), - to => _addresses($msg->to), - cc => _addresses($msg->cc), - bcc => _addresses($msg->bcc), - date => _iso8601_utc($msg->timestamp), - content_type => ''.$msg->contentType, - }, - }; - push @msgs2, $msg2; - } - - return \@msgs2; + @msgs = @msgs[$start..min($#msgs, $end-1)]; + + return [map { + { + byte_size => $_->size, + unread => !$_->label('seen'), + date_received => $_->guessTimestamp, + message_handle => $_->messageId, + head => _get_head_info($_), + } + } @msgs]; } sub count_messages { my ($f, $folder) = @_; - $folder = ".$folder"; - - _ok($folder ~~ [$f->listSubFolders, ROOT_MAILDIR]); - $f = $f->openSubFolder($folder) if $folder ne ROOT_MAILDIR; + $f = $f->openSubFolder(".$folder") if $folder; return { - count => scalar($f->messages('ALL')), - size => $f->size, - new => scalar $f->messages('!seen'), + total_mails => scalar $f->messages('ALL'), + byte_size => $f->size, + unread_mails => scalar $f->messages('!seen'), } } @@ -160,46 +187,55 @@ sub _iso8601_utc { sub _unquote { my $x = shift; [$x =~ m/"(.*?)"(?<!\\)/]->[0] || $x } sub _addresses { + return undef unless @_; [map { {address => $_->address, name => _unquote(decode('MIME-Header', $_->phrase))} } @_] } +sub _get_body { + my ($msg) = @_; + + if ($msg->isNested) { + my $nested = $msg->body->nested; + return { + head => _get_head_info($nested), + body => _get_body($nested), + }; + } + elsif ($msg->isMultipart) { + return { + preamble => ''.$msg->body->preamble, + parts => [map { { head => _get_mime_head_info($_), body => _get_body($_) } } $msg->parts], + epilogue => $msg->body->epilogue, + } + } + else { + return ''.$msg->decoded; + } +} + + sub read_mail { - my ($folder, $mid) = @_; + my ($f, $folder, $mid) = @_; + + $f = $folder->openSubFolder(".$folder") if $folder; - my $msg = $folder->find($mid); + my $msg = $f->find($mid); return {error => 'no such message', mid => $mid} unless $msg; return { - size => $msg->size, - head => { - subject => decode('MIME-Header', $msg->subject), - from => _addresses($msg->from), - to => _addresses($msg->to), - cc => _addresses($msg->cc), - bcc => _addresses($msg->bcc), - date => _iso8601_utc($msg->timestamp), - content_type => ''. $msg->contentType, - }, - body => do { - if ($msg->isMultipart) { - [map {{type => ''. $_->contentType, val => '' . $_->decoded}} $msg->body->parts] - } - else { - '' . $msg->body->decoded - } - }, + head => _get_head_info($msg), + body => _get_body($msg), } } sub search { my ($f, $search_pattern, $folder) = @_; - $folder = ".$folder"; - $f = $f->openSubFolder($folder) if $folder ne ROOT_MAILDIR; + $f = $f->openSubFolder(".$folder") if $folder; my @msgs = $f->messages(sub { - my $m = shift; + my ($m) = @_; return scalar(grep { $_->decoded =~ /$search_pattern/ || (decode('MIME-Header', $_->subject)) =~ /$search_pattern/ } $m->body->parts) if $m->isMultipart; @@ -209,17 +245,8 @@ sub search { my @msgs2; for my $msg (@msgs) { my $msg2 = { - size => $msg->size, - mid => $msg->messageId, - head => { - subject => decode('MIME-Header', $msg->subject), - from => _addresses($msg->from), - to => _addresses($msg->to), - cc => _addresses($msg->cc), - bcc => _addresses($msg->bcc), - date => _iso8601_utc($msg->timestamp), - content_type => ''. $msg->contentType, - }, + head => _get_head_info($msg), + body => '', }; push @msgs2, $msg2; } @@ -231,17 +258,18 @@ sub search { sub folders { my $f = shift; - return [grep { $_ =~ m/^\./ && $_ =~ s/\.// } $f->listSubFolders]; + return [map { s/^\.//r } $f->listSubFolders(check => 1)]; } sub move { - my ($f, $mid, $dst) = @_; - $dst = ".$dst"; + my ($f, $mid, $from, $to) = @_; - _ok($dst ~~ [$f->listSubFolders, ROOT_MAILDIR]); + $f = $f->openSubFolder(".$from") if $from; - $f->moveMessage($dst, $dst->find($mid)); + $f->find($mid)->moveTo($to ? ".$to" : $to); + + return 1; } @@ -269,20 +297,7 @@ a succsessful login. Input directives are provided as command line arguments. Output is delivered via STDOUT and log information via STDERR. -=head1 ARGUMENTS - - prog <maildir> <system-user> <mail-user> <mode> <args...> - -=head2 Modes - - list <start> <end> <sort-by> <folder> - count <folder> - read-mail <mid> - search <pattern> <folder> - folders - move <mid> <dst-folder> - -All arguments must be supplied for a given mode even if empty (as ''). +For the implemented interface see the README file. =head1 DEPENDENCIES @@ -290,6 +305,6 @@ Currently Mail::Box::Manager does all the hard work. =head1 SEE ALSO -L<JWebmail::Model::Driver::QMailAuthuser> +L<JWebmail::Model::ReadMails::QMailAuthuser> =cut diff --git a/script/qmauth.py b/script/qmauth.py new file mode 100755 index 0000000..7803483 --- /dev/null +++ b/script/qmauth.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 + +"""qmauth.py + +Extract delivers information about emails from a maildir. +Runs with elevated privileges. + +This program is started by qmail-authuser with elevated privileges after +a successful login. +Input directives are provided as command line arguments. +Output is delivered via STDOUT as json and log information via STDERR. + +Exit codes:: + + 1 reserved + 2 reserved + 3 operational error (error message in output) + 4 user error (no output) + 5 issue switching to user (no output) + 110 reserved + 111 reserved +""" + +import json +import logging +import re + +from argparse import ArgumentParser +from datetime import datetime +from email import message_from_binary_file, policy +from functools import cache +from glob import glob +from mailbox import Maildir, MaildirMessage +from os import environ, getpid, path, setuid, stat +from pathlib import Path +from pwd import getpwnam +from sys import exit as sysexit, stdout + + +class MyMaildir(Maildir): + + def __init__(self, dirname, *args, **kwargs): + self.__path = dirname + super().__init__(dirname, *args, **kwargs) + + def get_filename(self, mid): + if mid not in self: + raise KeyError(mid) + p_cur = glob(path.join(self.__path, 'cur', mid + '*')) + p_new = glob(path.join(self.__path, 'new', mid + '*')) + res = p_cur + p_new + if len(res) != 1: + raise LookupError("could not uniquely identify file for mail-id") + return res[0] + + def get_folder(self, folder): + # copy from internal implementation + return MyMaildir( + path.join(self._path, '.' + folder), factory=self._factory, create=False, + ) + + +@cache +def _file_size(fname): + return stat(fname).st_size + + +def _adr(addrs): + if addrs is None: + return None + return [ + {'address': addr.addr_spec, 'display_name': addr.display_name} + for addr in addrs.addresses + ] + + +def _get_rcv_time(mid): + idx = mid.find('.') + assert idx >= 0 + return float(mid[:idx]) + + +def startup(maildir, su, user): + + del environ['PATH'] + + netfehcom_uid = getpwnam(su).pw_uid + assert netfehcom_uid, "must be non root" + try: + setuid(netfehcom_uid) + except OSError: + logging.exception("error setting uid") + sysexit(5) + + return MyMaildir( + maildir / user, + create=False, + factory=lambda x: MaildirMessage( + message_from_binary_file(x, policy=policy.default) + ), + ) + + +def _sort_by_sender(midmsg): + _, msg = midmsg + + if len(addrs := msg['from'].addresses) == 1: + return addrs[0].addr_spec + else: + return msg['sender'].address.addr_spec + + +def _sort_mails(f, sort): + + reverse = False + if sort.startswith('!'): + reverse = True + sort = sort[1:] + + by_rec_date = lambda midmsg: float(re.match(r"\d+\.\d+", midmsg[0], re.ASCII)[0]) + + if sort == 'date': keyfn = by_rec_date + elif sort == 'sender': keyfn = _sort_by_sender + elif sort == 'subject': keyfn = lambda midmsg: midmsg[1]['subject'] + elif sort == 'size': keyfn = lambda midmsg: _file_size(f.get_filename(midmsg[0])) + elif sort == '': keyfn = by_rec_date + else: + logging.warning("unknown sort-verb %r", sort) + reverse = False + keyfn = by_rec_date + + return keyfn, reverse + + +def _get_mime_head_info(msg): + return { + 'content_maintype': msg.get_content_maintype(), + 'content_subtype': msg.get_content_subtype(), + 'content_disposition': msg.get_content_disposition(), + 'filename': msg.get_filename(), + } + + +def _get_head_info(msg): + return { + 'date': msg['date'].datetime.isoformat(), + + 'from': _adr(msg['from']), + 'sender': _adr(msg['sender']), + 'reply_to': _adr(msg['reply-to']), + + 'to': _adr(msg['to']), + 'cc': _adr(msg['cc']), + 'bcc': _adr(msg['bcc']), + + 'subject': msg['subject'], + 'comments': msg['comments'], + 'keywords': msg['keywords'], + + 'mime': _get_mime_head_info(msg), + } + + +def list_mails(f, start, end, sortby, folder): + + assert 0 <= start <= end + + if folder: + f = f.get_folder(folder) + + if start == end: + return [] + + kfn, reverse = _sort_mails(f, sortby) + msgs = list(f.items()) + msgs.sort(key=kfn, reverse=reverse) + msgs = msgs[start : min(len(msgs), end)] + + return [ + { + 'message_handle': mid, + 'byte_size': _file_size(f.get_filename(mid)), + 'unread': 'S' in msg.get_flags(), + 'date_received': datetime.fromtimestamp(_get_rcv_time(mid)).isoformat(), + 'head': _get_head_info(msg), + } + for mid, msg in msgs + ] + + +def count_mails(f, subfolder): + if subfolder: + f = f.get_folder(subfolder) + + return { + 'total_mails': len(f), + 'byte_size': sum(_file_size(f.get_filename(mid)) for mid in f.keys()), + 'unread_mails': len([1 for m in f if 'S' in m.get_flags()]), + } + + +def _get_body(mail): + if not mail.is_multipart(): + if mail.get_content_maintype() == 'text': + return mail.get_payload(decode=True).decode() + else: + return mail.get_payload() + + if (mctype := mail.get_content_maintype()) == 'message': + mm = mail.get_payload() + assert len(mm) == 1 + msg = mm[0] + return { + 'head': _get_head_info(msg), + 'body': msg.get_content(), + } + elif mctype == 'multipart': + return { + 'preamble': mail.preamble, + 'parts': [ + { + 'head': _get_mime_head_info(part), + 'body': _get_body(part), + } + for part in mail.get_payload() + ], + 'epilogue': mail.epilogue, + } + else: + raise ValueError(f"unknown major content-type {mctype!r}") + + +def read_mail(f, subfolder, mid): + if subfolder: + f = f.get_folder(subfolder) + + msg = f[mid] + if not msg: + return {'error': "no such message", 'mid': mid} + + return { + 'head': _get_head_info(msg), + 'body': _get_body(msg), + } + + +def _matches(m, pattern): + if m.is_multipart(): + return any( + 1 + for part in m.body.parts + if re.search(pattern, part.decoded()) or re.search(pattern, part.subject) + ) + return re.search(pattern, m.body.decoded()) or re.search(pattern, m.subject) + + +def search_mails(f, pattern: str, subfolder: str): + if subfolder: + f = f.get_folder(subfolder) + + return [ + { + 'head': _get_head_info(msg), + 'body': _get_body(msg), + } + for msg in f.values() + if _matches(msg, pattern) + ] + + +def folders(f): + return f.list_folders() + + +def move_mail(f, mid, from_, to): + if from_: + f = f.get_folder(from_) + + fname = Path(f.get_filename(mid)) + + assert to in f.list_folders() + + sep = -2 if not from_ else -3 + + if to: + res = fname.parts[:sep] + ('.' + to,) + fname.parts[-2:] + else: + res = fname.parts[:sep] + fname.parts[-2:] + + fname.rename(Path(*res)) + + return 1 + + +ap = ArgumentParser(allow_abbrev=False) +ap.add_argument('maildir_path', type=Path) +ap.add_argument('os_user') +ap.add_argument('mail_user') +sp = ap.add_subparsers(title='methods', required=True) + +sp_list = sp.add_parser('list') +sp_list.add_argument('folder', metavar='subfolder') +sp_list.add_argument('start', type=int) +sp_list.add_argument('end', type=int) +sp_list.add_argument('sortby', metavar='sort_by') +sp_list.set_defaults(run=list_mails) + +sp_count = sp.add_parser('count') +sp_count.add_argument('subfolder') +sp_count.set_defaults(run=count_mails) + +sp_read = sp.add_parser('read') +sp_read.add_argument('subfolder') +sp_read.add_argument('mid', metavar='message') +sp_read.set_defaults(run=read_mail) + +sp_folders = sp.add_parser('folders') +sp_folders.set_defaults(run=folders) + +sp_move = sp.add_parser('move') +sp_move.add_argument('mid', metavar='message') +sp_move.add_argument('from_', metavar='from') +sp_move.add_argument('to') +sp_move.set_defaults(run=move_mail) + +sp_search = sp.add_parser('search') +sp_search.add_argument('pattern') +sp_search.add_argument('subfolder') +sp_search.set_defaults(run=search_mails) + + +if __name__ == '__main__': + try: + logging.basicConfig( + level='INFO', + format="%(levelname)s:"+str(getpid())+":%(message)s", + ) + args = vars(ap.parse_args()) + logging.debug("started with %s", args) + s = startup(args.pop('maildir_path'), args.pop('os_user'), args.pop('mail_user')) + logging.debug("setuid successful") + run = args.pop('run') + reply = run(s, **args) + json.dump(reply, stdout) + if isinstance(reply, dict) and 'error' in reply: + sysexit(3) + except Exception: + logging.exception("qmauth.py error") + sysexit(4) diff --git a/t/Extract.t b/t/Extract.t index 7b1ddbf..3a18395 100644 --- a/t/Extract.t +++ b/t/Extract.t @@ -6,13 +6,13 @@ use Test::More; use JSON::PP 'decode_json'; use List::Util 'min'; -no warnings 'experimental::smartmatch'; my $EXTRACT = { - perl_mail_box => 'perl script/qmauth.pl ', - rust_maildir => 'extract/target/debug/jwebmail-extract', -}->{perl_mail_box}; + perl_mail_box => 'perl script/qmauth.pl', + python_mailbox => 'python script/qmauth.py', + rust_maildir => 'extract/target/debug/jwebmail-extract', +}->{python_mailbox}; my $MAILDIR = 't/'; my $SYS_USER = $ENV{USER}; my $MAIL_USER = 'maildir'; @@ -29,13 +29,15 @@ my $MAIL_COUNT = do { }; subtest start => sub { - my @res = `$PROG invalid`; + my @res = `$PROG invalid 2>/dev/null`; - is($? >> 8, 3); - is @res, 1; - my $result = decode_json $res[0]; + isnt $? >> 8, 0; - ok($result->{error}) + if ($? >> 8 == 3) { + is scalar(@res), 1; + my $result = decode_json @res; + ok exists $result->{error}; + } }; subtest folders => sub { @@ -55,43 +57,44 @@ subtest count => sub { is @res, 1; my $result = decode_json $res[0]; - is($result->{count}, $MAIL_COUNT); - #is($result->{new}, 0); + is($result->{total_mails}, $MAIL_COUNT); }; subtest list => sub { - my $s = "$PROG list 0 4 date ''"; + my $s = "$PROG list '' 0 4 date"; my @res = `$s`; is($? >> 8, 0); is @res, 1; my $result = decode_json $res[0]; - is(@$result, min($MAIL_COUNT, 5)); - ok($result->[0]{mid}); + is(@$result, min($MAIL_COUNT, 4)); + ok($result->[0]{message_handle}); ok($result->[0]{head}{from}) or diag $s; ok($result->[0]{head}{to}); }; subtest read => sub { - my @pre_res = `$PROG list 0 10 date ''`; + my $folder = ''; + my @pre_res = `$PROG list '$folder' 0 10 ''`; is($? >> 8, 0); is @pre_res, 1; my $pre_result = decode_json $pre_res[0]; - ok(my $mid = $pre_result->[0]{mid}); + ok(my $mid = $pre_result->[0]{message_handle}); - my $s = "$PROG read-mail '$mid'"; - my @res = `$s`; + my @res = `$PROG read '$folder' '$mid'`; - is($? >> 8, 0) or (diag $s, return); + is($? >> 8, 0, "read exit code") or (diag @res, return); is @res, 1; my $result = decode_json $res[0]; - is_deeply($result->{head}{from}, [{address => 'shipment-tracking@amazon.de', name => 'Amazon.de'}]); - ok($result->{date_received}); - ok(index($result->{date_received}, '2019-02-22T10:06:54') != -1); - like($result->{date_received}, qr'2019-02-22T10:06:54'); + is_deeply( + $result->{head}{from}, + [{address => 'xy@example.com', display_name => 'Moderator-Address'}], + ); + ok(exists $result->{head}{date}); + is($result->{head}{date}, '1994-03-22T13:34:51+00:00'); }; done_testing; @@ -85,73 +85,73 @@ subtest 'pagination' => sub { %res = JWebmail::Plugin::Helper::_paginate(first_item => 0, page_size => 10, total_items => 55); - is $res{first_item}, 1; - is $res{last_item}, 10; + is $res{first_item}, 0; + is $res{last_item}, 9; is $res{total_items}, 55; is $res{page_size}, 10; is $res{total_pages}, 6; - is $res{current_page}, 1; + is $res{current_page}, 0; - is_deeply $res{first_page}, [1, 10], 'first'; - is_deeply $res{prev_page}, [1, 10], 'prev'; - is_deeply $res{next_page}, [11, 20], 'next'; - is_deeply $res{last_page}, [51, 55], 'last'; + is_deeply $res{first_page}, [0, 10], 'first'; + is_deeply $res{prev_page}, [0, 10], 'prev'; + is_deeply $res{next_page}, [10, 20], 'next'; + is_deeply $res{last_page}, [50, 55], 'last'; %res = JWebmail::Plugin::Helper::_paginate(first_item => 10, page_size => 10, total_items => 55); - is $res{first_item}, 11; - is $res{last_item}, 20; + is $res{first_item}, 10; + is $res{last_item}, 19; is $res{total_items}, 55; is $res{page_size}, 10; is $res{total_pages}, 6; - is $res{current_page}, 2; + is $res{current_page}, 1; - is_deeply $res{first_page}, [1, 10], 'first'; - is_deeply $res{prev_page}, [1, 10], 'prev'; - is_deeply $res{next_page}, [21, 30], 'next'; - is_deeply $res{last_page}, [51, 55], 'last'; + is_deeply $res{first_page}, [0, 10], 'first'; + is_deeply $res{prev_page}, [0, 10], 'prev'; + is_deeply $res{next_page}, [20, 30], 'next'; + is_deeply $res{last_page}, [50, 55], 'last'; %res = JWebmail::Plugin::Helper::_paginate(first_item => 20, page_size => 10, total_items => 55); - is $res{first_item}, 21; - is $res{last_item}, 30; + is $res{first_item}, 20; + is $res{last_item}, 29; is $res{total_items}, 55; is $res{page_size}, 10; is $res{total_pages}, 6; - is $res{current_page}, 3; + is $res{current_page}, 2; - is_deeply $res{first_page}, [1, 10], 'first'; - is_deeply $res{prev_page}, [11, 20], 'prev'; - is_deeply $res{next_page}, [31, 40], 'next'; - is_deeply $res{last_page}, [51, 55], 'last'; + is_deeply $res{first_page}, [0, 10], 'first'; + is_deeply $res{prev_page}, [10, 20], 'prev'; + is_deeply $res{next_page}, [30, 40], 'next'; + is_deeply $res{last_page}, [50, 55], 'last'; %res = JWebmail::Plugin::Helper::_paginate(first_item => 50, page_size => 10, total_items => 55); - is $res{first_item}, 51; - is $res{last_item}, 55; + is $res{first_item}, 50; + is $res{last_item}, 54; is $res{total_items}, 55; is $res{page_size}, 10; is $res{total_pages}, 6; - is $res{current_page}, 6; + is $res{current_page}, 5; - is_deeply $res{first_page}, [1, 10], 'first'; - is_deeply $res{prev_page}, [41, 50], 'prev'; - is_deeply $res{next_page}, [51, 55], 'next'; - is_deeply $res{last_page}, [51, 55], 'last'; + is_deeply $res{first_page}, [0, 10], 'first'; + is_deeply $res{prev_page}, [40, 50], 'prev'; + is_deeply $res{next_page}, [50, 55], 'next'; + is_deeply $res{last_page}, [50, 55], 'last'; %res = JWebmail::Plugin::Helper::_paginate(first_item => 0, page_size => 10, total_items => 0); - is $res{first_item}, 0; - is $res{last_item}, 0; + ok !defined $res{first_item}; + ok !defined $res{last_item}; is $res{total_items}, 0; is $res{page_size}, 10; is $res{total_pages}, 0; - is $res{current_page}, 1; + is $res{current_page}, 0; is_deeply $res{first_page}, [0, 0], 'first'; is_deeply $res{prev_page}, [0, 0], 'prev'; @@ -181,4 +181,4 @@ subtest 'pagination' => sub { }; -done_testing;
\ No newline at end of file +done_testing; diff --git a/t/Webmail.t b/t/Webmail.t index 9218fb1..3deb547 100644 --- a/t/Webmail.t +++ b/t/Webmail.t @@ -14,9 +14,11 @@ my $pw = JWebmail::Model::ReadMails::MockJSON::VALID_PW; my $t = Test::Mojo->new('JWebmail', { - development => { read_mock => 'JWebmail::Model::ReadMails::MockJSON', block_writes => 1 }, - i18n => { default_language => DEFAULT_LANGUAGE }, - session => { secure => 'none' }, + model => { read => { devel => { driver => 'JWebmail::Model::ReadMails::MockJSON' }}, + write => { devel => { block_writes => 1 }}}, + i18n => { default_language => DEFAULT_LANGUAGE, directory => 'lib/JWebmail/I18N' }, + session => { secure => 'none' }, + defaults => { scriptadmin => 'test@example.org' }, }); $t->get_ok('/')->status_is(200); diff --git a/templates/headers/_display_bot_nav.html.ep b/templates/headers/_display_bot_nav.html.ep index 5a58b77..7be2832 100644 --- a/templates/headers/_display_bot_nav.html.ep +++ b/templates/headers/_display_bot_nav.html.ep @@ -10,14 +10,16 @@ </div> <div class="pure-u-1-1 pure-u-md-1-2"> - %= form_for move => (id => 'move-mail') => (class => 'pure-form') => begin - <fieldset> - %= label_for 'select-folder' => l('move to') - %= select_field folder => [grep {$_ ne $folder} @$mail_folders] => (id => 'select-folder') - %= csrf_field - %= submit_button l('move') => (class => 'pure-button') - </fieldset> - % end + % if (grep {$_ ne $folder} @$mail_folders) { + %= form_for move => (id => 'move-mail') => (class => 'pure-form') => begin + <fieldset> + %= label_for 'select-folder' => l('move to') + %= select_field folder => [map { $_ ? $_ : l 'Home' } grep {$_ ne $folder} @$mail_folders] => (id => 'select-folder') + %= csrf_field + %= submit_button l('move') => (class => 'pure-button') + </fieldset> + % end + % } </div> </div> diff --git a/templates/headers/_display_folders.html.ep b/templates/headers/_display_folders.html.ep index 856844d..7f1612f 100644 --- a/templates/headers/_display_folders.html.ep +++ b/templates/headers/_display_folders.html.ep @@ -8,10 +8,10 @@ </strong> <ul class="pure-menu-list"> -% for my $v (grep {$_ ne $folder} @$mail_folders) { +% for (grep {$_ ne $folder} @$mail_folders) { <li class="pure-menu-item"> - %= link_to '' => {folder => $v} => (class => 'bright') => begin - %= l($v || '_mailbox_root') + %= link_to '' => {folder => $_} => (class => 'bright') => begin + %= l($_ || '_mailbox_root') % end </li> % } @@ -21,7 +21,7 @@ </div> <p class="pure-u-1-1 pure-u-md-1-2"> - <%= l('[_1] of [_2] messages', $last_item - $first_item + 1, $total_items) %>\ + <%= l('[_1] of [_2] messages', $this_page->[1] - $this_page->[0], $total_items) %>\ <%= l(', [_1] new', $total_new_mails) if $total_new_mails > 0 =%> <%= l(' - mailbox size: [_1]', print_sizes10 $total_size) if $total_size %> </p> diff --git a/templates/headers/_display_headers.html.ep b/templates/headers/_display_headers.html.ep index 42f927c..4dd36a1 100644 --- a/templates/headers/_display_headers.html.ep +++ b/templates/headers/_display_headers.html.ep @@ -66,9 +66,9 @@ % foreach my $msgnum ($first_item .. $last_item) { % my $msg = $msgs->[$msgnum - $first_item]; - %= tag tr => (class => $msg->{new} ? 'new-mail' : '') => (id => $msg->{mid}) => begin + %= tag tr => (class => $msg->{unread} ? 'new-mail' : '') => (id => $msg->{message_handle}) => begin <td class="hide-small"> - %= $msgnum + %= $msgnum + 1 </td> <td> @@ -76,7 +76,7 @@ <!-- <div class="pure-u-1-4"> - %# ucfirst($msg->{is_multipart} ? l('yes') : l('no')); + %# ucfirst($msg->{head}{mime}{content_maintype} eq 'multipart' ? l('yes') : l('no')); </div> --> @@ -86,22 +86,23 @@ </div> <div class="pure-u-16-24 pure-u-md-6-24"> - %= $msg->{head}{from}[0]{name} || $msg->{head}{from}[0]{email}; + <%= $msg->{head}{sender}[0]{display_name} || $msg->{head}{sender}[0]{address} || + $msg->{head}{from}[0]{display_name} || $msg->{head}{from}[0]{address}; %> </div> <div class="pure-u-20-24 pure-u-md-12-24"> - %= link_to $msg->{head}{subject} || '_' => read => {id => $msg->{mid}} + %= link_to $msg->{head}{subject} || '_' => read => {id => $msg->{message_handle}} </div> <div class="pure-u-4-24 pure-u-md-2-24"> - %= print_sizes10 $msg->{size}; + %= print_sizes10 $msg->{byte_size}; </div> </div> </td> <td> - %= check_box mail => $msg->{mid} => (form => 'move-mail') + %= check_box mail => $msg->{message_handle} => (form => 'move-mail') </td> % end diff --git a/templates/headers/_pagination1.html.ep b/templates/headers/_pagination1.html.ep index 9b6121a..0e000f9 100644 --- a/templates/headers/_pagination1.html.ep +++ b/templates/headers/_pagination1.html.ep @@ -1,7 +1,7 @@ <div> - <a href="<%= url_with->query({start => $prev_page->[0]-1}) %>"><img src="/left.gif" alt="←"></a> - <a href="<%= url_with->query({start => $first_page->[0]-1}) %>"><img src="/first.gif" alt="↞"></a> - [<%= l('page [_1] of [_2]', $current_page, $total_pages) %>] - <a href="<%= url_with->query({start => $last_page->[0]-1}) %>"><img src="/last.gif" alt="↠"></a> - <a href="<%= url_with->query({start => $next_page->[0]-1}) %>"><img src="/right.gif" alt="→"></a> + <a href="<%= url_with->query({start => $prev_page->[0]}) %>"> ← </a> + <a href="<%= url_with->query({start => $first_page->[0]}) %>"> ↞ </a> + [<%= l('page [_1] of [_2]', $current_page+1, $total_pages) %>] + <a href="<%= url_with->query({start => $last_page->[0]}) %>"> ↠ </a> + <a href="<%= url_with->query({start => $next_page->[0]}) %>"> → </a> </div> diff --git a/templates/headers/_pagination2.html.ep b/templates/headers/_pagination2.html.ep index 236e9bb..63e8f63 100644 --- a/templates/headers/_pagination2.html.ep +++ b/templates/headers/_pagination2.html.ep @@ -1,10 +1,10 @@ <div> %= form_for '' => (class => 'pure-form') => begin - <a href="<%= url_with->query({start => $first_page->[0]-1}) %>"><img src="/first.gif" alt="<%= l('first') . ' ' . l 'page' %>"></a> - <a href="<%= url_with->query({start => $prev_page->[0]-1}) %>"><img src="/left.gif" alt="<%= l('previous') . ' ' . l 'page' %>"></a> + <a href="<%= url_with->query({start => $first_page->[0]}) %>"><img src="/first.gif" alt="<%= l('first') . ' ' . l 'page' %>"></a> + <a href="<%= url_with->query({start => $prev_page->[0]}) %>"><img src="/left.gif" alt="<%= l('previous') . ' ' . l 'page' %>"></a> [ %= label_for custompage => ucfirst l 'page' - %= number_field start => (id => 'custompage') => (size => 3) => (placeholder => $current_page) + %= number_field start => (id => 'custompage') => (size => 3) => (placeholder => $current_page+1) %= l 'of' %= $total_pages ] @@ -16,7 +16,7 @@ % } % } - <a href="<%= url_with->query({start => $next_page->[0]-1}) %>"><img src="/right.gif" alt="<%= l('next') . ' ' . l 'page' %>"></a> - <a href="<%= url_with->query({start => $last_page->[0]-1}) %>"><img src="/last.gif" alt="<%= l('last') . ' ' . l('page') %>"></a> + <a href="<%= url_with->query({start => $next_page->[0]}) %>"><img src="/right.gif" alt="<%= l('next') . ' ' . l 'page' %>"></a> + <a href="<%= url_with->query({start => $last_page->[0]}) %>"><img src="/last.gif" alt="<%= l('last') . ' ' . l('page') %>"></a> % end </div> diff --git a/templates/webmail/readmail.html.ep b/templates/webmail/readmail.html.ep index 5bad9f3..b5b48a1 100644 --- a/templates/webmail/readmail.html.ep +++ b/templates/webmail/readmail.html.ep @@ -2,10 +2,14 @@ % my $mail_fmt = begin % my ($category, $value) = @_; - <dt> <%= ucfirst l $category %> </dt> - <dd> - %= ref $value ? join(' ' . l('and') . ' ', map {"$_->{name} <$_->{address}>"} @$value) : $value - </dd> + % if (ref $value eq 'ARRAY' && $value->@*) { + <dt> <%= uc l $category %> </dt> + % for ($value->@*) { + <dd> + %= $_->{name} ? qq("$_->{name}" <$_->{address}>) : "$_->{address}" + </dd> + % } + % } % end <div class="jwm-base"> @@ -16,42 +20,43 @@ <dt> <%= uc l 'subject' %> </dt> <dd> <%= $msg->{head}{subject} %> </dd> - %= $mail_fmt->('from', $msg->{head}{from}) - %= $mail_fmt->('to', $msg->{head}{to}) - %= $mail_fmt->('cc', $msg->{head}{cc}) if !ref $msg->{head}{cc} || @{ $msg->{head}{cc} } - %= $mail_fmt->('bcc', $msg->{head}{bcc}) if !ref $msg->{head}{bcc} || @{ $msg->{head}{bcc} } + %= $mail_fmt->(from => $msg->{head}{from}) + %= $mail_fmt->(to => $msg->{head}{to}) + %= $mail_fmt->(cc => $msg->{head}{cc}) + %= $mail_fmt->(bcc => $msg->{head}{bcc}) <dt> <%= uc l 'date' %> </dt> <dd> <%= $msg->{head}{date} %> </dd> - <dt> <%= uc l 'size' %> </dt> - <dd> <%= print_sizes10 $msg->{size} %> </dd> - + % my $content_type = $msg->{head}{mime}{content_maintype} . '/' . $msg->{head}{mime}{content_subtype}; <dt> <%= uc l 'content-type' %> </dt> - <dd> <%= $msg->{head}{content_type} %> </dd> + <dd> <%= $content_type %> </dd> </dl> % my $body = $msg->{body}; -% if ($msg->{head}{content_type} eq 'multipart/alternative') { -% for (reverse @$body) { +% if ($content_type eq 'multipart/alternative') { +% for (reverse @{$body->{parts}}) { <div class=jwm-mail-body> -% my $x = mime_render($_->{head}{content_type}, $_->{body}); +% my $x = mime_render($_->{head}{content_maintype}.'/'.$_->{head}{content_subtype}, $_->{body}); %== $x; </div> % last if $x; % } % } -% elsif (ref $body eq 'HASH') { -% for (%$body) { +% elsif ($msg->{head}{mime}{content_maintype} eq 'multipart') { +% for (@{$body->{parts}}) { <div class=jwm-mail-body> - %== mime_render($_->{head}{content_type}, $_->{body}); + %== mime_render($_->{head}{content_maintype}.'/'.$_->{head}{content_subtype}, $_->{body}); </div> % } % } +% elsif ($msg->{head}{mime}{content_maintype} eq 'message') { +% die "not implemented" +% } % else { <div class=jwm-mail-body> - %== mime_render($msg->{head}{content_type}, $body); + %== mime_render($content_type, $body); </div> % } |