diff options
author | Jannis M. Hoffmann <jannis@fehcom.de> | 2023-03-10 13:54:57 +0100 |
---|---|---|
committer | Jannis M. Hoffmann <jannis@fehcom.de> | 2023-03-10 13:54:57 +0100 |
commit | fcf5549584b69e62b6c2f0eb919f6799c7904211 (patch) | |
tree | e5f0e480af0f39f1c0f457ea0aca8d33f8fb4d0b | |
parent | df59f9dec32d7f8f08706fd3eb5b784deaa0abfc (diff) |
Proper recursive rendering of mails to html
1. Added raw mode to model
2. Added raw route
3. Moved readmail view parts to RenderMail plugin
4. Renamed displayheaders partial templates
21 files changed, 440 insertions, 278 deletions
@@ -134,13 +134,14 @@ Current v1.1.0 - [ ] moving mails to other folders - [ ] creating new folders - [ ] backend -- [ ] specify protocol for backend interaction (RPC IDL) +- [x] specify protocol for backend interaction (RPC IDL) - [ ] choose tool for validation - [ ] cleanup README - [ ] improve about page - [ ] improve performance of backend, consider alternatives to Mail::Box::Manager - [ ] based on Maildir::Light - [x] reimplementation in Rust + - [x] reimplementation in Python - [ ] cache mail reads - is unlikely to work as mails can be moved freely - [ ] add exception types @@ -153,12 +154,18 @@ Current v1.1.0 - [ ] add config validation - [ ] improve i18n - [ ] add localization of dates and time + - [ ] plural handling - [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 +- [x] separate namespace for pagination in the stash + +- [ ] Rust IO error ?? +- [x] Read attachments +- [x] display recursive multipart +- [ ] iframe load confirmation Future ------ @@ -19,6 +19,7 @@ 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/RenderMail.pm lib/JWebmail/Plugin/ServerSideSessionData.pm t/Webmail.t @@ -32,14 +33,14 @@ templates/webmail/displayheaders.html.ep templates/webmail/about.html.ep templates/webmail/login.html.ep templates/layouts/mainlayout.html.ep -templates/headers/_display_top_nav.html.ep -templates/headers/_display_bot_nav.html.ep -templates/headers/_display_folders.html.ep -templates/headers/_display_headers.html.ep -templates/headers/_pagination2.html.ep -templates/headers/_pagination1.html.ep +templates/displayheaders/_top_nav.html.ep +templates/displayheaders/_bot_nav.html.ep +templates/displayheaders/_folders.html.ep +templates/displayheaders/_main_table.html.ep +templates/displayheaders/_pagination2.html.ep +templates/displayheaders/_pagination1.html.ep templates/not_found_.html.ep -templates/error.html.ep +templates/exception_.html.ep public/style.css diff --git a/lib/JWebmail.pm b/lib/JWebmail.pm index 3abc78d..6963d44 100644 --- a/lib/JWebmail.pm +++ b/lib/JWebmail.pm @@ -44,6 +44,7 @@ sub startup { $self->plugin('ServerSideSessionData'); } $self->plugin('Helper'); + $self->plugin('RenderMail'); my $i18n_route = $self->plugin('I18N2', $self->config('i18n')); $self->secrets( [$self->config('secret')] ) if $self->config('secret'); @@ -85,17 +86,18 @@ sub route { my $r = shift || $self->routes; - $r->get('/' => 'login')->to('Webmail#noaction'); + $r->get( '/' => 'login')->to('Webmail#noaction'); $r->post('/' => 'login')->to('Webmail#login'); - $r->get('/about')->to('Webmail#about'); - $r->get('/logout')->to('Webmail#logout'); + $r->get('/about' )->to('Webmail#about'); + $r->get('/logout' )->to('Webmail#logout'); my $a = $r->under('/')->to('Webmail#auth'); - $a->get('/home/*folder')->to('Webmail#displayheaders', folder => '')->name('displayheaders'); + $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('/move')->to('Webmail#move'); + $a->get('/raw/#id' => 'raw' )->to('Webmail#raw'); + $a->get('/write' )->to('Webmail#writemail'); + $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 6d0cc55..fd1c499 100644 --- a/lib/JWebmail/Controller/Webmail.pm +++ b/lib/JWebmail/Controller/Webmail.pm @@ -73,7 +73,7 @@ sub login { my $passwd = $v->required('password')->size(4, 50)->like(qr/^.+$/)->param; # no new-lines my $challenge; if ($uses_cram) { - $challenge = $v->required('challenge')->size(4, 50)->param; # no new-lines + $challenge = $v->required('challenge')->size(4, 50)->param; } if ($v->has_error) { @@ -190,43 +190,42 @@ sub readmail { my $self = shift; my $mid = $self->stash('id'); - my $auth = $self->stash(ST_AUTH); my $mail; my $ok = eval { $mail = $self->users->show($auth, '', $mid); 1 }; if (!$ok) { - my $err = $@; - if ($err =~ m/unkown mail-id|no such message/) { + my $err = "$@"; + if ($err =~ /unkown mail-id|no such message/) { $self->reply->not_found; return; } die; } + $self->stash(msg => $mail); +} + + +sub raw { + my $self = shift; + + my $mid = $self->stash('id'); + my $auth = $self->stash(ST_AUTH); + # select a single body element my $v = $self->validation; - my $type = $v->optional('body')->like(qr(^[\w\-/; ]+$)a)->param; - return if $v->has_error; + my $path = $v->optional('path')->like(qr(^\d(\.\d)*$)a)->param; + return $self->render(text => 'Issue in parameter "path": '.join(' ', $v->error('path')->@*), status => 400, format => 'txt') if $v->has_error; - if ($type) { - if ($mail->{head}{mime}{content_maintype} eq 'multipart') { - my $content = first {$_->{head}{content_subtype} eq $type} $mail->{body}{parts}->@*; - $self->render(text => $content->{body}); - } - elsif ($mail->{head}{mime}{content_subtype} eq $type) { - $self->render(text => $mail->{body}); - } - else { - $self->reply->not_found; - } - return; - } + my $content = $self->users->raw($auth, '', $mid, $path); - $self->respond_to( - html => {msg => $mail}, - json => {json => $mail} - ); + $self->res->headers->content_disposition(qq[attachment; filename="$content->{head}{filename}"]) + if $content->{head}{content_disposition}; + my $ct = $self->to_mime_type($content->{head}); + if ($ct eq 'text/plain') { $ct .= '; charset=UTF-8' } + $self->res->headers->content_type($ct); + $self->render(data => $content->{body}); } @@ -369,9 +368,3 @@ Sends a mail written in writemail. =head2 move Moves mails between mail forlders. - -=head1 DEPENCIES - -Mojolicious and File::Type - -=cut diff --git a/lib/JWebmail/Model/ReadMails/MockJSON.pm b/lib/JWebmail/Model/ReadMails/MockJSON.pm index 6b3b6d2..e429d53 100644 --- a/lib/JWebmail/Model/ReadMails/MockJSON.pm +++ b/lib/JWebmail/Model/ReadMails/MockJSON.pm @@ -109,6 +109,13 @@ sub show { sub folders { ['', qw(cur test devel debug)] } +sub raw { + my $self = shift; + my ($auth, $folder, $mid, $path) = @_; + + ... +} + sub search { ... } sub move { ... } diff --git a/lib/JWebmail/Model/ReadMails/MockMaildir.pm b/lib/JWebmail/Model/ReadMails/MockMaildir.pm index 9b1bb29..de5c745 100644 --- a/lib/JWebmail/Model/ReadMails/MockMaildir.pm +++ b/lib/JWebmail/Model/ReadMails/MockMaildir.pm @@ -14,12 +14,12 @@ use constant { has user => sub { $ENV{USER} }; has maildir => 't/'; -has extractor => 'perl'; +has extractor => 'python'; our %EXTRACTORS = ( - perl => 'perl script/qmauth.pl', - python => 'python script/qmauth.py', - rust => 'extract/target/debug/jwebmail-extract', + perl => 'script/qmauth.pl', + python => 'script/qmauth.py', + rust => 'bin/jwebmail-extract', ); @@ -51,33 +51,17 @@ sub verify_user { } } -sub build_and_run { +sub start_qmauth { my $self = 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)); + my @exec = ($EXTRACTORS{$self->extractor}, $self->maildir, $self->user, $mail_user, $mode, @$args); - my $pid = open(my $reader, '-|', $exec) - or die 'failed to create subprocess'; + my $pid = open(my $reader, '-|', @exec) + or die "failed to create subprocess: $!"; - my $input = <$reader>; - - waitpid($pid, 0); - my $rc = $? >> 8; - - my $resp; - if ($rc == 3 || $rc == 0) { - eval { $resp = decode_json $input; }; - if (my $err = $@) { $resp = {error => "decoding error '$err'"}; $rc ||= 1; }; - } - elsif ($rc) { - $resp = {error => "qmauth returned code: $rc"}; - } - - local $" = ', '; - die "error @{[%$resp]}" if $rc; - return $resp; + return $pid, $reader; } diff --git a/lib/JWebmail/Model/ReadMails/QMailAuthuser.pm b/lib/JWebmail/Model/ReadMails/QMailAuthuser.pm index a61cf01..e16e2f2 100644 --- a/lib/JWebmail/Model/ReadMails/QMailAuthuser.pm +++ b/lib/JWebmail/Model/ReadMails/QMailAuthuser.pm @@ -36,7 +36,7 @@ package JWebmail::Model::ReadMails::QMailAuthuser::Error { sub to_string { my $self = shift; - my $verbose = shift; + my $verbose = 1; #shift; if ($verbose && defined $self->{data}) { my $errstr = Data::Dumper->new([$self->{data}])->Terse(1)->Indent(0)->Quotekeys(0)->Dump; @@ -139,6 +139,13 @@ sub show { return $self->build_and_run($auth, 'read', [$folder, $mid]); } +sub raw { + my $self = shift; + my ($auth, $folder, $mid, $path) = @_; + + return $self->build_and_run($auth, 'raw', [$folder, $mid, $path//'']); +} + sub search { my $self = shift; my ($auth, $pattern, $folder) = @_; @@ -175,34 +182,69 @@ 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, $exec) = @_; +sub start_qmauth { + my $self = shift; + my ($auth, $mode, $args) = @_; + + my $exec = $self->build_arg($auth->{user}, $mode, $args); my $pid = open2(my $reader, my $writer, $exec) - or die 'failed to create subprocess'; + or die "failed to create subprocess: $!"; my $challenge = $auth->{challenge} || ''; $writer->print("$auth->{user}\0$auth->{password}\0$challenge\0") - or die 'pipe wite failed'; + or die "pipe wite failed: $!"; close $writer - or die 'closing write pipe failed'; + or die "closing write pipe failed: $!"; + + return $pid, $reader; +} + +sub read_qmauth { + my $_self = shift; + my ($pid, $reader) = @_; - #binmode $reader, ':encoding(UTF-8)'; my $input = <$reader>; - close $reader - or die 'closing read pipe failed'; - waitpid $pid, 0; - my $rc = $? >> 8; + my $rc; + if (eof $reader) { + # for regular open + close $reader + or warn "closing read pipe failed: $!"; + $rc = $? >> 8; + + # for IPC::Open2 + if (waitpid($pid, 0) == $pid) { + $rc = $? >> 8; + } + } my $resp; - if ($rc == 3 || $rc == 0) { + if (!defined $rc) { + my ($r, $e); + eval { $r = decode_json $input; 1 } + or do { + $rc = 6; + $e = "$@"; + }; + $reader->read(my $buf, 4 * 1024**2); + if (!eof $reader) { + die 'mailpart too large (>4MB)' + } + close $reader; + $resp = { + head => $r, + body => $buf, + rc => $rc, + e => $e, + }; + } + elsif ($rc == 3 || $rc == 0) { eval { $resp = decode_json $input if $input; 1 } or do { $resp = { @@ -214,7 +256,7 @@ sub execute { $rc = 3; }; } - elsif ($rc) { + else { $resp = { info => "got unsuccessful return code by qmail-authuser", return_code => $rc, @@ -229,8 +271,8 @@ sub build_and_run { my $self = shift; my ($auth, $mode, $args) = @_; - my $exec = $self->build_arg($auth->{user}, $mode, $args); - my ($resp, $rc) = $self->execute($auth, $exec); + my @exec = $self->start_qmauth($auth, $mode, $args||[]); + my ($resp, $rc) = $self->read_qmauth(@exec); if ($rc) { JWebmail::Model::ReadMails::QMailAuthuser::Error->throw( diff --git a/lib/JWebmail/Model/ReadMails/Role.pm b/lib/JWebmail/Model/ReadMails/Role.pm index d6fa1e5..ae113de 100644 --- a/lib/JWebmail/Model/ReadMails/Role.pm +++ b/lib/JWebmail/Model/ReadMails/Role.pm @@ -46,6 +46,7 @@ my @methods = ( 'read_headers_for', # auth:Auth, *folder='', *start=0, *end=24, *sort='date' -> ^ :hashref 'search', # auth:Auth, pattern, folder -> ^ :hashref 'show', # auth:Auth, mid -> ^ :hashref + 'raw', ); requires(@methods); diff --git a/lib/JWebmail/Plugin/Helper.pm b/lib/JWebmail/Plugin/Helper.pm index a98f245..b298a17 100644 --- a/lib/JWebmail/Plugin/Helper.pm +++ b/lib/JWebmail/Plugin/Helper.pm @@ -98,45 +98,6 @@ sub parse_iso_date { }; } -sub to_mime_type { - my $c = shift; - my ($mime_head) = @_; - - return "$mime_head->{content_maintype}/$mime_head->{content_subtype}"; -} - - -### mime type html render functions - -my $render_text_plain = sub { - my ($c, $content) = @_; - - $content = xml_escape($content); - $content =~ s/\n/<br>/g; - - return $content; -}; - -my $render_text_html = sub { - my $c_ = shift; - - return '<iframe src="' . $c_->url_for('read', id => $c_->stash('id'))->query(body => 'html') . '" class=html-mail></iframe>'; -}; - -our %MIME_Render_Subs = ( - 'text/plain' => $render_text_plain, - 'text/html' => $render_text_html, -); - -sub mime_render { - my ($c, $enc, $cont) = @_; - - ($enc) = $enc =~ m<^(\w+/\w+);?>; - - my $renderer = $MIME_Render_Subs{$enc} // return; - return $renderer->($c, $cont); -}; - ### session password handling @@ -210,7 +171,7 @@ sub warn_crypt { state $once = 0; - if ( !TRUE_RANDOM && !$once && lc($c->config->{session}{secure}) eq 's3d' ) { + if ( !TRUE_RANDOM && !$once && lc $c->config->{session}{secure} eq 's3d' ) { $c->log->warn("Falling back to pseudo random generation. Please install Crypt::URandom"); $once = 1; } @@ -308,8 +269,6 @@ sub register { if contains 'parse_iso_date'; $app->helper(print_sizes2 => sub { shift; print_sizes2(@_) }) if contains 'print_sizes2'; - $app->helper(to_mime_type => \&to_mime_type) - if contains 'to_mime_type'; $app->helper(mime_render => \&mime_render) if contains 'mime_render'; $app->helper(session_passwd => \&session_passwd) @@ -324,7 +283,6 @@ sub register { elsif (!$conf->{import}) { # default imports $app->helper(print_sizes10 => sub { shift; print_sizes10(@_) }); $app->helper(parse_iso_date => sub { shift; parse_iso_date(@_) }); - $app->helper(to_mime_type => \&to_mime_type); $app->helper(mime_render => \&mime_render); $app->helper(session_passwd => \&session_passwd); $app->helper(paginate => \&paginate); @@ -333,6 +291,9 @@ sub register { $app->validator->add_filter(non_empty_ul => \&filter_empty_upload); } + else { + die 'unkown value for "import"' + } } @@ -350,16 +311,10 @@ Helper - Functions used as helpers in controller and templates and additional va use Mojo::Base 'Mojolicious'; - use JWebmail::Plugin::Helper; - sub startup($self) { - $self->helper(mime_render => \&JWebmail::Plugin::Helper::mime_render); + $app->plugin('Helper'); } - # or - - $app->plugin('Helper'); - =head1 DESCRIPTION L<JWebmail::Helper> provides useful helper functions and validator cheks and filter for @@ -425,15 +380,6 @@ Sets the stash values (all 1 based inclusive): next_page last_page -=head2 mime_render - -A helper for templates used to display the content of a mail for the browser. -The output is valid html and should not be escaped. - - $app->helper(mime_render => \&JWebmail::Plugin::Helper::mime_render); - - %== mime_render 'text/plain' $content - =head2 session_passwd A helper used to set and get the session password. The behavior can be altered by @@ -469,11 +415,11 @@ On log-in it is transfered plainly. =head1 DEPENDENCIES -Mojolicious and optionally Digest::HMAC_MD5, Crypt::URandom. +Mojolicious and recommended Crypt::URandom. =head1 SEE ALSO -L<JWebmail>, L<JWebmail::Controller::All>, L<Mojolicious>, L<Mojolicious::Controller> +L<JWebmail> =head1 NOTICE diff --git a/lib/JWebmail/Plugin/RenderMail.pm b/lib/JWebmail/Plugin/RenderMail.pm new file mode 100644 index 0000000..4417fae --- /dev/null +++ b/lib/JWebmail/Plugin/RenderMail.pm @@ -0,0 +1,182 @@ +package JWebmail::Plugin::RenderMail; + +use Mojo::Base 'Mojolicious::Plugin'; + +use Mojo::ByteStream; +use Mojo::Util 'xml_escape'; + + +sub render_text_plain { + my ($_c, $_subtype, $content, $_path) = @_; + + $content = xml_escape($content); + $content =~ s/\n/<br>/g; + + return qq'<div class="jwm-mail-body jwm-mail-body-text-plain">\n $content </div>\n'; +} + +sub render_text_html { + my ($c, $_subtype, $_content, $path) = @_; + + my $url = $c->url_for('raw', id => $c->stash('id')); + $url = $url->query(path => join('.', @$path)) if @$path; + + return qq'<iframe src="$url" class="jwm-mail-body-text-html" ></iframe>\n'; +} + +sub render_multipart_alternative { + my ($c, $_subtype, $content, $path) = @_; + + my $parts = $content->{parts}; + my $R = qq'<div class="jwm-mail-body jwm-mail-body-multipart-alternative"\n>'; + my $i = 0; + my $end; + + for (reverse @$parts) { + if (!$end) { + my $x = mime_render($c, to_mime_types($_->{head}), $_->{body}, [@$path, $#$parts-$i]); + if ($x) { + $R .= $x; + $end = 1; + } + } + else { + $R .= '<details class="jwm-mail-body-multipart-alternative-extra" >'; + $R .= '<summary>'; + $R .= to_mime_type($_->{head}); + $R .= "</summary>\n"; + $R .= mime_render($c, to_mime_types($_->{head}), $_->{body}, [@$path, $#$parts-$i]); + $R .= "</details>\n"; + } + ++$i; + } + return $R . "</div>\n"; +} + +sub render_multipart { + my ($c, $_subtype, $content, $path) = @_; + + my $parts = $content->{parts}; + my $R = qq'<div class="jwm-mail-body jwm-mail-body-multipart"\n>'; + my $i = 0; + + for (@$parts) { + if ( !$_->{head}{content_disposition} + || lc $_->{head}{content_disposition} eq 'none' + || lc $_->{head}{content_disposition} eq 'inline') { + + $R .= mime_render($c, to_mime_types($_->{head}), $_->{body}, [@$path, $i]); + } + elsif (lc $_->{head}{content_disposition} eq 'attachment') { + $R .= '<p>'; + $R .= $c->link_to($c->url_for(raw => id => $c->stash('id'))->query(path => join('.', @$path, $i))->to_abs, (download => $_->{head}{filename}) => sub { + 'Attachment ' . xml_escape($_->{head}{filename}) . ' of type ' . to_mime_type($c, $_->{head}); + }); + $R .= "</p>\n"; + } + else { + warn "unknown Content-Disposition '$_->{head}{content_disposition}'"; + $R .= "<p>unknown Content-Disposition '$_->{head}{content_disposition}'</p>\n"; + } + ++$i; + } + return $R . "</div>\n"; +} + +sub _format_header { + my ($c, $category, $value) = @_; + + my $R = ''; + + if (ref $value eq 'ARRAY' && $value->@*) { + $R .= '<dt>' . xml_escape(uc $c->l($category)) . "</dt>\n"; + for ($value->@*) { + $R .= '<dd>'; + $R .= xml_escape($_->{display_name} ? qq("$_->{display_name}" <$_->{address}>) : "$_->{address}"); + $R .= "<dd>\n"; + } + } + return $R; +} + +sub render_message { + my ($c, $subtype, $msg, $path) = @_; + + warn "unkown mime-subtype $subtype" unless $subtype eq 'rfc822'; + + my $R .= '<div clas="jwm-mail">'; + + $R .= '<dl class="jwm-mail-header">'; + $R .= '<dt>' . xml_escape(uc $c->l('subject')) . '</dt>'; + $R .= '<dd>' . xml_escape($msg->{head}{subject}) . "</dd>\n"; + $R .= _format_header($c, from => $msg->{head}{from}); + $R .= _format_header($c, to => $msg->{head}{to}); + $R .= _format_header($c, cc => $msg->{head}{cc}); + $R .= _format_header($c, bcc => $msg->{head}{bcc}); + $R .= '<dt>' . xml_escape(uc $c->l('date')) . '</dt>'; + $R .= '<dd>' . xml_escape($msg->{head}{date}) . "</dd>\n"; + $R .= '<dt>' . xml_escape(uc $c->l('content-type')) . '</dt>'; + $R .= '<dd>' . to_mime_type($msg->{head}{mime}) . "</dd>\n"; + $R .= "</dl>\n"; + + #my $content = ref $msg->{body} && exists $msg->{body}{parts} ? $msg->{body}{parts} : $msg->{body}; + + $R .= mime_render($c, to_mime_types($msg->{head}{mime}), $msg->{body}, [@$path, 0]); + + return $R . "</div>\n"; +} + +our %MIME_Render_Subs = ( + 'text/plain' => \&render_text_plain, + 'text/html' => \&render_text_html, + 'multipart/alternative' => \&render_multipart_alternative, + 'multipart' => \&render_multipart, + 'message' => \&render_message, +); + +sub mime_render { + my ($c, $maintype, $subtype, $content, $path) = @_; + + my $renderer = $MIME_Render_Subs{"$maintype/$subtype"} || $MIME_Render_Subs{$maintype}; + + unless ($renderer) { + return "<p>Unsupported MIME type of <code>$maintype/$subtype</code>.</p>\n"; + } + + return $renderer->($c, $subtype, $content, $path); +} + + +sub to_mime_type { lc xml_escape("$_[0]->{content_maintype}/$_[0]->{content_subtype}") } +sub to_mime_types { return xml_escape($_[0]->{content_maintype}), xml_escape($_[0]->{content_subtype}) } + + +sub register { + my ($self, $app, $conf) = @_; + $conf //= {}; + + $app->helper('render_mail.format_mail' => sub { Mojo::ByteStream->new(mime_render($_[0], 'message', 'rfc822', $_[1], [])) }); + $app->helper(to_mime_type => sub { shift; to_mime_type(@_) }); +} + +1 + +__END__ + +=encoding utf-8 + +=head1 NAME + +JWebmail::Plugin::RenderMail - Does the heavy lifting of converting an E-Mail to HTML + +=head1 HELPERS + +=head2 render_mail.format_mail + +Renders a mail to html recursively. + +=head2 to_mime_type + +Combines the content_maintype and content_subtype attributes into the regular MIME description. +These attributes are found in a mail head mime section or as head for multipart messages. + diff --git a/public/style.css b/public/style.css index 16bc419..a62ef9c 100644 --- a/public/style.css +++ b/public/style.css @@ -62,10 +62,10 @@ footer { padding: 10px; } - .jwm-mail-body > iframe.html-mail { - width: 100%; - height: 400px; - } +iframe.jwm-mail-body-text-html { + width: 100%; + height: 400px; +} .jwm-mail-body-text-plain { background-color: var(--jwm-color-alt); diff --git a/script/qmauth.py b/script/qmauth.py index 38e5d58..662b39c 100755 --- a/script/qmauth.py +++ b/script/qmauth.py @@ -31,6 +31,7 @@ from argparse import ArgumentParser from datetime import datetime from functools import cache from glob import glob +from itertools import islice from mailbox import Maildir, MaildirMessage from os import environ, getpid, path, setuid, stat from pathlib import Path @@ -51,7 +52,7 @@ class MyMaildir(Maildir): 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") + raise LookupError(f"could not uniquely identify file for mail-id {mid!r}", mid) return res[0] def get_folder(self, folder): @@ -61,6 +62,13 @@ class MyMaildir(Maildir): ) +class QMAuthError(Exception): + + def __init__(self, msg, **args): + self.msg = msg + self.info = args + + @cache def _file_size(fname): return stat(fname).st_size @@ -81,24 +89,34 @@ def _get_rcv_time(mid): return float(mid[:idx]) -def startup(maildir, su, user, headers_only): +def startup(maildir, su, user, mode): del environ['PATH'] netfehcom_uid = getpwnam(su).pw_uid - assert netfehcom_uid, "must be non root" + if not netfehcom_uid: + logging.error("user must not be root") + sysexit(5) try: setuid(netfehcom_uid) except OSError: logging.exception("error setting uid") sysexit(5) + def create_messages(mail_file): + if mode == count_mails: + msg = MaildirMessage(None) + elif mode == list_mails: + msg = MaildirMessage(email.parser.BytesHeaderParser(policy=email.policy.default).parse(mail_file)) + else: + msg = email.parser.BytesParser(policy=email.policy.default).parse(mail_file) + + return msg + return MyMaildir( maildir / user, create=False, - factory=lambda x: MaildirMessage( - email.parser.BytesParser(policy=email.policy.default).parse(x, headersonly=headers_only) - ), + factory=create_messages, ) @@ -203,32 +221,36 @@ def count_mails(f, subfolder): def _get_body(mail): if not mail.is_multipart(): if mail.get_content_maintype() == 'text': - return mail.get_payload(decode=True).decode( - encoding=mail.get_content_charset(failobj='utf-8'), - errors='replace') + return mail.get_content() else: - return mail.get_payload() + ret = mail.get_content() + if ret.isascii(): + return ret.decode(encoding='ascii') + raise ValueError(f"unsupported content type in leaf part {mail.get_content_type()}") if (mctype := mail.get_content_maintype()) == 'message': - mm = mail.get_payload() - assert len(mm) == 1 - msg = mm[0] + msg = mail.get_content() return { 'head': _get_head_info(msg), - 'body': msg.get_content(), + 'body': _get_body(msg), } elif mctype == 'multipart': - return { + ret = { 'preamble': mail.preamble, - 'parts': [ - { - 'head': _get_mime_head_info(part), - 'body': _get_body(part), - } - for part in mail.get_payload() - ], + 'parts': [], 'epilogue': mail.epilogue, } + for part in mail.iter_parts(): + head = _get_mime_head_info(part) + if not head['content_disposition'] == 'attachment': + body = _get_body(part) + else: + body = None + ret['parts'].append({ + 'head': head, + 'body': body, + }) + return ret else: raise ValueError(f"unknown major content-type {mctype!r}") @@ -237,9 +259,9 @@ def read_mail(f, subfolder, mid): if subfolder: f = f.get_folder(subfolder) - msg = f[mid] + msg = f.get(mid, None) if not msg: - return {'error': "no such message", 'mid': mid} + raise QMAuthError("no such message", mid=mid) return { 'head': _get_head_info(msg), @@ -247,6 +269,60 @@ def read_mail(f, subfolder, mid): } +def _descent(xx): + head = _get_mime_head_info(xx) + if (mctype := head['content_maintype']) == 'message': + body = list(xx.iter_parts())[0] + elif mctype == 'multipart': + body = xx.iter_parts() + else: + body = xx.get_content() + return { + 'head': head, + 'body': body, + } + + +def raw_mail(f, subfolder, mid, path): + if subfolder: + f = f.get_folder(subfolder) + + pth = [int(seg) for seg in path.split('.')] if path else [] + mail = { + 'head': {"content_maintype": "message", "content_subtype": "rfc822"}, + 'body': f[mid], + } + + for n in pth: + mctype = mail['head']['content_maintype'] + + if mctype == 'multipart': + try: + res = next(islice(mail['body'], n, None)) + except StopIteration: + raise QMAuthError("out of bounds path for mail", path=pth) + mail = _descent(res) + elif mctype == 'message': + assert n == 0 + mail = _descent(mail['body']) + else: + raise QMAuthError(f"can not descent into non multipart content type {mctype}") + + if hasattr(mail['body'], '__next__'): + raise QMAuthError("can not stop at multipart section", path=pth) + + json.dump(mail['head'], stdout) + stdout.write("\n") + if type(mail['body']) is str: + stdout.write(mail['body']) + elif type(mail['body']) is bytes: + stdout.flush() + stdout.buffer.write(mail['body']) + else: + stdout.write(str(mail['body'])) + sysexit(0) + + def _matches(m, pattern): if m.is_multipart(): return any( @@ -317,6 +393,12 @@ sp_read.add_argument('subfolder') sp_read.add_argument('mid', metavar='message') sp_read.set_defaults(run=read_mail) +sp_raw = sp.add_parser('raw') +sp_raw.add_argument('subfolder') +sp_raw.add_argument('mid', metavar='message') +sp_raw.add_argument('path', default='') +sp_raw.set_defaults(run=raw_mail) + sp_folders = sp.add_parser('folders') sp_folders.set_defaults(run=folders) @@ -344,14 +426,16 @@ if __name__ == '__main__': args.pop('maildir_path'), args.pop('os_user'), args.pop('mail_user'), - args['run'] == list_mails, + args['run'], ) 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 QMAuthError as qerr: + errmsg = dict(error=qerr.msg, **qerr.info) + json.dump(errmsg, stdout) + sysexit(3) except Exception: logging.exception("qmauth.py error") sysexit(4) diff --git a/templates/headers/_display_bot_nav.html.ep b/templates/displayheaders/_bot_nav.html.ep index 9dc808a..3eb57d3 100644 --- a/templates/headers/_display_bot_nav.html.ep +++ b/templates/displayheaders/_bot_nav.html.ep @@ -1,7 +1,7 @@ <div class="pure-g jwm-nav"> <div class="pure-u-3-4 pure-u-md-1-4"> - %= include 'headers/_pagination1' + %= include 'displayheaders/_pagination1' </div> <div class="pure-u-1-4 pure-u-md-1-4"> diff --git a/templates/headers/_display_folders.html.ep b/templates/displayheaders/_folders.html.ep index be5bdd9..be5bdd9 100644 --- a/templates/headers/_display_folders.html.ep +++ b/templates/displayheaders/_folders.html.ep diff --git a/templates/headers/_display_headers.html.ep b/templates/displayheaders/_main_table.html.ep index 6c00d0b..5430c15 100644 --- a/templates/headers/_display_headers.html.ep +++ b/templates/displayheaders/_main_table.html.ep @@ -76,7 +76,7 @@ <!-- <div class="pure-u-1-4"> - %= ucfirst($msg->{head}{mime}{content_maintype} eq 'multipart' ? l('yes') : l('no')); + %# ucfirst($msg->{head}{mime}{content_maintype} eq 'multipart' ? l('yes') : l('no')); </div> --> diff --git a/templates/headers/_pagination1.html.ep b/templates/displayheaders/_pagination1.html.ep index 798f79f..798f79f 100644 --- a/templates/headers/_pagination1.html.ep +++ b/templates/displayheaders/_pagination1.html.ep diff --git a/templates/headers/_pagination2.html.ep b/templates/displayheaders/_pagination2.html.ep index 8bff0bf..8bff0bf 100644 --- a/templates/headers/_pagination2.html.ep +++ b/templates/displayheaders/_pagination2.html.ep diff --git a/templates/headers/_display_top_nav.html.ep b/templates/displayheaders/_top_nav.html.ep index ca5001b..fd6bae6 100644 --- a/templates/headers/_display_top_nav.html.ep +++ b/templates/displayheaders/_top_nav.html.ep @@ -23,7 +23,7 @@ </div> <div class="pure-u-1-1 pure-u-md-1-2"> - %= include 'headers/_pagination2'; + %= include 'displayheaders/_pagination2'; </div> <!-- delete button diff --git a/templates/error.html.ep b/templates/exception_.html.ep index 3a1d5e3..a605aaf 100644 --- a/templates/error.html.ep +++ b/templates/exception_.html.ep @@ -10,7 +10,7 @@ <h1>Error</h1> <p class=center> % if (my $msg = stash 'error') { - %= $msg +%= $msg % } % else { Uwu :( @@ -20,10 +20,10 @@ % if (my $see_other = stash 'links') { See: <nav> - % for (@$see_other) { - %= link_to $_ => $_ +% for (@$see_other) { +%= link_to $_ => $_ <br> - % } +% } </nav> % } </body> diff --git a/templates/webmail/displayheaders.html.ep b/templates/webmail/displayheaders.html.ep index 23f48d3..3f650c0 100644 --- a/templates/webmail/displayheaders.html.ep +++ b/templates/webmail/displayheaders.html.ep @@ -2,7 +2,7 @@ <div id=displayheaders> - %= include 'headers/_display_folders'; + %= include 'displayheaders/_folders'; % if (my $loginmessage = stash 'loginmessage') { <p id=loginmessage> @@ -10,10 +10,10 @@ </p> % } - %= include 'headers/_display_top_nav'; + %= include 'displayheaders/_top_nav'; % if (@$msgs) { - %= include 'headers/_display_headers'; + %= include 'displayheaders/_main_table'; % } % else { <p id=empty-folder> @@ -21,6 +21,6 @@ </p> % } - %= include 'headers/_display_bot_nav'; + %= include 'displayheaders/_bot_nav'; </div> diff --git a/templates/webmail/readmail.html.ep b/templates/webmail/readmail.html.ep index e1f299d..5bdc27e 100644 --- a/templates/webmail/readmail.html.ep +++ b/templates/webmail/readmail.html.ep @@ -1,98 +1,11 @@ % layout 'mainlayout'; -% my $mail_fmt = begin -% my ($category, $value) = @_; -% if (ref $value eq 'ARRAY' && $value->@*) { - <dt> <%= uc l $category %> </dt> -% for ($value->@*) { - <dd> -%= $_->{display_name} ? qq("$_->{display_name}" <$_->{address}>) : "$_->{address}" - </dd> -% } -% } -% end - - -% my $format_mail = begin -% my ($msg) = @_; -% my $body = $msg->{body}; -% my $content_type = to_mime_type $msg->{head}{mime}; - - <div clas="jwm-mail"> - - <dl class="jwm-mail-header"> - <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}) -%= $mail_fmt->(bcc => $msg->{head}{bcc}) - <dt> <%= uc l 'date' %> </dt> - <dd> <%= $msg->{head}{date} %> </dd> - <dt> <%= uc l 'content-type' %> </dt> - <dd> <%= $content_type %> </dd> - </dl> - -% if ($content_type eq 'multipart/alternative') { -% my $end; -% for (reverse $body->{parts}->@*) { -% if (!$end) { - <div class="jwm-mail-body <%= to_mime_type($_->{head}) eq 'text/plain' ? 'jwm-mail-body-text-plain' : '' %>" > -% my $x = mime_render(to_mime_type($_->{head}), $_->{body}); -%== $x; - </div> -% $end = 1 if $x; -% } -% else { - <details class="jwm-mail-body <%= to_mime_type($_->{head}) eq 'text/plain' ? 'jwm-mail-body-text-plain' : '' %>" > - <summary> -%= to_mime_type $_->{head} - </summary> -%== mime_render(to_mime_type($_->{head}), $_->{body}) - </details> -% } -% } -% } -% elsif ($msg->{head}{mime}{content_maintype} eq 'multipart') { -% for ($body->{parts}->@*) { -% if ( !$_->{head}{content_disposition} -% || lc $_->{head}{content_disposition} eq 'none' -% || lc $_->{head}{content_disposition} eq 'inline') { - <div class="jwm-mail-body <%= to_mime_type($_->{head}) eq 'text/plain' ? 'jwm-mail-body-text-plain' : '' %>" > -%== mime_render(to_mime_type($_->{head}), $_->{body}) // "Can not render mime-part of type <code>$_->{head}{content_maintype}/$_->{head}{content_subtype}</code>." - </div> -% } -% elsif (lc $_->{head}{content_disposition} eq 'attachment') { - <p> - Attachment <%= $_->{head}{filename} %> of type -%= to_mime_type $_->{head} - </p> -% } -% else { -% die "unknown Content-Disposition '$_->{head}{content_disposition}'" -% } -% } -% } -% elsif ($msg->{head}{mime}{content_maintype} eq 'message') { -% die "not implemented" unless $msg->{head}{mime}{content_subtype} eq 'rfc822'; - <p>not implemented</p> -% } -% else { - <div class="jwm-mail-body <%= $content_type eq 'text/plain' ? 'jwm-mail-body-text-plain' : '' %>" > -%== mime_render($content_type, $body) // $content_type - </div> -% } - - </div> -% end - - <div class="jwm-base"> <h1>Read Mail</h1> -%= $format_mail->($msg) +%= $c->render_mail->format_mail($msg) <nav> <a href="javascript:history.back()" class="pure-button"> |