diff options
author | Jannis M. Hoffmann <jannis@fehcom.de> | 2023-03-13 21:34:03 +0100 |
---|---|---|
committer | Jannis M. Hoffmann <jannis@fehcom.de> | 2023-03-13 21:34:03 +0100 |
commit | 8ee2d7149baa58ea225cb40e0f95030ee21f1081 (patch) | |
tree | 11d2bd52f36d3d566f9abcb30654b9bd78e56422 | |
parent | 6441b5ad6657873fcd8f3695515fa6ef3bc4e6f5 (diff) |
Split up Helper plugin and added Views instead
-rw-r--r-- | MANIFEST | 7 | ||||
-rw-r--r-- | lib/JWebmail.pm | 58 | ||||
-rw-r--r-- | lib/JWebmail/Controller/Webmail.pm | 166 | ||||
-rw-r--r-- | lib/JWebmail/Plugin/Helper.pm | 426 | ||||
-rw-r--r-- | lib/JWebmail/Plugin/Paginate.pm | 145 | ||||
-rw-r--r-- | lib/JWebmail/View/RenderMail.pm (renamed from lib/JWebmail/Plugin/RenderMail.pm) | 79 | ||||
-rw-r--r-- | lib/JWebmail/View/Webmail.pm | 88 | ||||
-rw-r--r-- | t/Pagination.t (renamed from t/Helper.t) | 91 | ||||
-rw-r--r-- | t/ViewWebmail.t | 84 | ||||
-rw-r--r-- | templates/displayheaders/_folders.html.ep | 2 | ||||
-rw-r--r-- | templates/displayheaders/_main_table.html.ep | 4 | ||||
-rw-r--r-- | templates/not_found.production.html.ep (renamed from templates/not_found_.html.ep) | 0 | ||||
-rw-r--r-- | templates/webmail/readmail.html.ep | 2 |
13 files changed, 582 insertions, 570 deletions
@@ -13,14 +13,15 @@ lib/JWebmail/Model/ReadMails/MockMaildir.pm lib/JWebmail/Model/ReadMails/MockJSON.pm lib/JWebmail/Model/ReadMails/QMailAuthuser.pm 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/RenderMail.pm +lib/JWebmail/Plugin/Paginate.pm lib/JWebmail/Plugin/ServerSideSessionData.pm +lib/JWebmail/View/RenderMail.pm +lib/JWebmail/View/Webmail.pm t/Webmail.t t/INI.t @@ -39,7 +40,7 @@ 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/not_found.production.html.ep templates/exception_.html.ep public/style.css diff --git a/lib/JWebmail.pm b/lib/JWebmail.pm index eb03167..e672903 100644 --- a/lib/JWebmail.pm +++ b/lib/JWebmail.pm @@ -43,8 +43,7 @@ sub startup { if (fc $self->config->{session}{secure} eq fc 's3d') { $self->plugin('ServerSideSessionData'); } - $self->plugin('Helper'); - $self->plugin('RenderMail'); + $self->plugin('Paginate'); my $i18n_route = $self->plugin('I18N2', $self->config('i18n')); $self->secrets( [$self->config('secret')] ) if $self->config('secret'); @@ -70,6 +69,9 @@ sub startup { $JWebmail::Model::WriteMails::Block_Writes = 1 if $self->mode eq 'development' && $self->config->{model}{write}{devel}{block_writes}; + $self->validator->add_check(mail_line => \&_mail_line); + $self->validator->add_filter(non_empty_ul => \&_filter_empty_upload); + $self->defaults(version => __PACKAGE__->VERSION); $self->sessions->cookie_name('jwebmail_session'); @@ -101,6 +103,38 @@ sub route { } +### filter and checks for mojo validator + +sub _mail_line { + my ($v, $name, $value, @args) = @_; + + my $mail_addr = qr/\w+\@\w+\.\w+/; + # my $unescaped_quote = qr/"(*nlb:\\)/; # greater perl version required + my $unescaped_quote = qr/"(?<!:\\)/; + + return $value !~ /^( + ( + ( + ( + $unescaped_quote.*?$unescaped_quote + ) | ( + [\w\s]* + ) + ) + \s*<$mail_addr> + ) | ( + $mail_addr + ))$ + /xn; +} + +sub _filter_empty_upload { + my ($v, $name, $value) = @_; + + return $value->filename ? $value : undef; +} + + 1 __END__ @@ -123,6 +157,26 @@ And use a server in reverse proxy configuration. Use the I<jwebmail.conf> file or for a specific I<$mode> the I<jwebmail.$mode.conf>. +=head1 VALIDATOR + +=head2 mail_line + +A check for validator used in mail headers for fields containing email addresses. + + $app->validator->add_check(mail_line => \&JWebmail::Plugin::Helper::mail_line); + + my $v = $c->validation; + $v->required('to', 'not_empty')->check('mail_line'); + +=head2 filter_empty_upload + +A filter for validator used to filter out empty uploads. + + $app->validator->add_filter(non_empty_ul => \&JWebmail::Plugin::Helper::filter_empty_upload); + + my $v = $c->validation; + $v->required('file_upload', 'non_empty_ul'); + =head1 AUTHORS Copyright (C) 2020-2022 Jannis M. Hoffmann L<jannis@fehcom.de> diff --git a/lib/JWebmail/Controller/Webmail.pm b/lib/JWebmail/Controller/Webmail.pm index 8325050..acf7557 100644 --- a/lib/JWebmail/Controller/Webmail.pm +++ b/lib/JWebmail/Controller/Webmail.pm @@ -2,13 +2,20 @@ package JWebmail::Controller::Webmail; use Mojo::Base Mojolicious::Controller; +use Carp 'carp'; use List::Util 'first'; +use Mojo::Util qw(encode decode b64_encode b64_decode); use Mojolicious::Types; +use JWebmail::View::Webmail; +use JWebmail::View::RenderMail; + +use constant TRUE_RANDOM => eval { require Crypt::URandom; Crypt::URandom->import('urandom'); 1 }; + use constant { - S_USER => 'user', # Key for user name in active session - ST_AUTH => 'auth', + SES_USER => 'user', # Key for user name in active session + STS_AUTH => 'auth', }; @@ -16,7 +23,7 @@ use constant { sub noaction { my $self = shift; - my $user = $self->session(S_USER); + my $user = $self->session(SES_USER); if ($user) { $self->res->code(307); $self->redirect_to('home'); @@ -30,8 +37,8 @@ sub noaction { sub auth { my $self = shift; - my $user = $self->session(S_USER); - my ($pw, $ch) = $self->session_passwd(); + my $user = $self->session(SES_USER); + my ($pw, $ch) = $self->_session_passwd(); unless ($user && $pw) { $self->flash(message => $self->l('No active session.')); @@ -40,7 +47,7 @@ sub auth { return 0; } - $self->stash(ST_AUTH() => $self->users->Auth(user => $user, password => $pw, challenge => $ch)); + $self->stash(STS_AUTH() => $self->users->Auth(user => $user, password => $pw, challenge => $ch)); return 1; } @@ -86,8 +93,8 @@ sub login { my $valid = _time { $self->users->verify_user($auth) } $self, 'verify user'; if ($valid) { - $self->session(S_USER() => $user); - $self->session_passwd($passwd, $challenge); + $self->session(SES_USER() => $user); + $self->_session_passwd($passwd, $challenge); $self->res->code(303); $self->redirect_to('displayheaders'); @@ -104,8 +111,8 @@ sub login { sub logout { my $self = shift; - delete $self->session->{S_USER()}; - $self->session_passwd(''); + delete $self->session->{SES_USER()}; + $self->_session_passwd(''); # $self->session(expires => 1); @@ -130,7 +137,7 @@ sub displayheaders { no warnings 'experimental::smartmatch'; my $self = shift; - my $auth = $self->stash(ST_AUTH); + my $auth = $self->stash(STS_AUTH); my $folders = _time { $self->users->folders($auth) } $self, 'user folders'; @@ -178,6 +185,7 @@ sub displayheaders { $self->app->log->debug(sprintf("Reading user headers took %fs", $elapsed)); $self->stash( + v => JWebmail::View::Webmail->new, msgs => $headers, mail_folders => $folders, total_size => $count->{byte_size}, @@ -190,7 +198,7 @@ sub readmail { my $self = shift; my $mid = $self->stash('id'); - my $auth = $self->stash(ST_AUTH); + my $auth = $self->stash(STS_AUTH); my $mail; my $ok = eval { $mail = $self->users->show($auth, '', $mid); 1 }; @@ -203,7 +211,10 @@ sub readmail { die; } - $self->stash(msg => $mail); + $self->stash( + v => JWebmail::View::RenderMail->new(c => $self), + msg => $mail, + ); } @@ -211,7 +222,7 @@ sub raw { my $self = shift; my $mid = $self->stash('id'); - my $auth = $self->stash(ST_AUTH); + my $auth = $self->stash(STS_AUTH); # select a single body element my $v = $self->validation; @@ -221,9 +232,9 @@ sub raw { my $content = $self->users->raw($auth, '', $mid, $path); $self->res->headers->content_disposition(qq[attachment; filename="$content->{head}{filename}"]) - if lc $content->{head}{content_disposition} eq 'attachment'; - my $ct = $self->to_mime_type($content->{head}); - if ($ct eq 'text/plain') { $ct .= '; charset=UTF-8' } + if lc ($content->{head}{content_disposition}//'') eq 'attachment'; + my $ct = JWebmail::View::RenderMail::to_mime_type($content->{head}); + if ($ct =~ m'^text/') { $ct .= '; charset=UTF-8' } $self->res->headers->content_type($ct); $self->render(data => $content->{body}); } @@ -246,7 +257,7 @@ sub sendmail { bcc => scalar $v->optional('bcc', 'not_empty')->check('mail_line')->every_param, reply => scalar $v->optional('back_to', 'not_empty')->check('mail_line')->param, attach => scalar $v->optional('attach', 'non_empty_ul')->upload->param, - from => scalar $self->stash(ST_AUTH)->{user}, + from => scalar $self->stash(STS_AUTH)->{user}, ); $mail{attach_type} = Mojolicious::Types->new()->file_type($mail{attach}->filename) if $mail{attach}; @@ -287,7 +298,7 @@ sub move { return; } - my $auth = $self->stash(ST_AUTH); + my $auth = $self->stash(STS_AUTH); my $folders = $self->users->folders($auth); my $mm = $self->every_param('mail'); @@ -304,6 +315,85 @@ sub move { } +### session password handling + +use constant { S_PASSWD => 'pw', S_OTP_S3D_PW => 'otp_s3d_pw' }; + +sub _rand_data { + my $len = shift; + + if (TRUE_RANDOM) { + #return makerandom_octet(Length => $len, Strength => 0); # was used for Crypt::Random + return urandom($len); + } + else { + my $res = ''; + for (0..$len-1) { + vec($res, $_, 8) = int rand 256; + } + + return $res; + } +} + +sub _session_passwd { + my ($self, $passwd, $challenge) = @_; + my $secAlg = $self->config->{session}{secure}; + + $self->_warn_crypt; + + if (defined $passwd) { # set + if ($secAlg eq 'cram') { + $self->session(S_PASSWD() => $passwd, challenge => $challenge); + } + elsif ($secAlg eq 's3d') { + unless ($passwd) { + $self->s3d(S_PASSWD, ''); + delete $self->session->{S_OTP_S3D_PW()}; + return; + } + die "'$passwd' contains invalid character \\n" if $passwd =~ /\n/; + if (length $passwd < 20) { + $passwd .= "\n" . ' ' x (20 - length($passwd) - 1); + } + my $passwd_utf8 = encode('UTF-8', $passwd); + my $rand_bytes = _rand_data(length $passwd_utf8); + $self->s3d(S_PASSWD, b64_encode($passwd_utf8 ^ $rand_bytes, '')); + $self->session(S_OTP_S3D_PW, b64_encode($rand_bytes, '')); + } + else { + $self->session(S_PASSWD() => $passwd); + } + } + else { # get + if ($secAlg eq 'cram') { + wantarray or carp "you forgot the challenge"; + return ($self->session(S_PASSWD), $self->session('challenge')); + } + elsif ($secAlg eq 's3d') { + my $pw = b64_decode($self->s3d(S_PASSWD) || ''); + my $otp = b64_decode($self->session(S_OTP_S3D_PW) || ''); + my ($res) = split "\n", decode('UTF-8', $pw ^ $otp), 2; + return $res; + } + else { + return $self->session(S_PASSWD); + } + } +} + +sub _warn_crypt { + my $self = shift; + + state $once = 0; + + if ( !TRUE_RANDOM && !$once && lc $self->config->{session}{secure} eq 's3d' ) { + $self->log->warn("Falling back to pseudo random generation. Please install Crypt::URandom"); + $once = 1; + } +} + + 1 __END__ @@ -368,3 +458,41 @@ Sends a mail written in writemail. =head2 move Moves mails between mail forlders. + +=head1 METHODS + +=head2 _session_passwd + +A helper used to set and get the session password. The behavior can be altered by +setting the config variable C<< session => {secure => 's3d'} >>. + + $c->_session_passwd('s3cret'); + +Currently the following modes are supported: + +=over 6 + +=item none + +The password is plainly stored in session cookie. +The cookie is stored on the client side and send with every request. + +=item cram + +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 + +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 + +=head1 DEPENDENCIES + +Crypt::URandom is recommended + diff --git a/lib/JWebmail/Plugin/Helper.pm b/lib/JWebmail/Plugin/Helper.pm deleted file mode 100644 index b298a17..0000000 --- a/lib/JWebmail/Plugin/Helper.pm +++ /dev/null @@ -1,426 +0,0 @@ -package JWebmail::Plugin::Helper; - -use Mojo::Base Mojolicious::Plugin; - -use List::Util qw(all any min max); -use Carp 'carp'; -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 }; - - -### filter and checks for mojo validator - -sub mail_line { - my ($v, $name, $value, @args) = @_; - - my $mail_addr = qr/\w+\@\w+\.\w+/; - # my $unescaped_quote = qr/"(*nlb:\\)/; # greater perl version required - my $unescaped_quote = qr/"(?<!:\\)/; - - return $value !~ /^( - ( - ( - ( - $unescaped_quote.*?$unescaped_quote - ) | ( - [\w\s]* - ) - ) - \s*<$mail_addr> - ) | ( - $mail_addr - ))$ - /xn; -} - -sub filter_empty_upload { - my ($v, $name, $value) = @_; - - return $value->filename ? $value : undef; -} - - -### template formatting functions - -sub print_sizes10 { - my $var = shift || return '0 Byte'; - - my $i = floor(((log($var)/log(10))+1e-5) / 3); - my $expo = $i * 3; - - my @PREFIX; - $PREFIX[0] = 'Byte'; - $PREFIX[1] = 'kByte'; - $PREFIX[2] = 'MByte'; - $PREFIX[3] = 'GByte'; - $PREFIX[4] = 'TByte'; - $PREFIX[5] = 'PByte'; - - return sprintf('%.0f %s', $var / (10**$expo), $PREFIX[$i]); -} - -sub print_sizes2 { - my $var = shift || return '0 Byte'; - - my $i = floor(((log($var)/log(2))+1e-5) / 10); - my $expo = $i * 10; - my %PREFIX = ( - 0 => 'Byte', - 1 => 'KiByte', - 2 => 'MiByte', - 3 => 'GiByte', - 4 => 'TiByte', - 5 => 'PiByte', - ); - my $pref = $PREFIX{$i}; - return round($var / (2**$expo)) . " $pref"; -} - -my sub dgt { "([[:digit:]]{$_[0]})" } - -sub parse_iso_date { - state $rx = do { my $re = dgt(4).'-'.dgt(2).'-'.dgt(2).'T'.dgt(2).':'.dgt(2).':'.dgt(2); qr/$re/a }; - my @d = shift =~ /$rx/; - if (@d != 6) { - # TODO - warn "issue when parsing date"; - } - return { - year => $d[0], - month => $d[1], - mday => $d[2], - hour => $d[3], - min => $d[4], - sec => $d[5], - }; -} - - -### session password handling - -use constant { S_PASSWD => 'pw', S_OTP_S3D_PW => 'otp_s3d_pw' }; - -sub _rand_data { - my $len = shift; - - if (TRUE_RANDOM) { - #return makerandom_octet(Length => $len, Strength => 0); # was used for Crypt::Random - return urandom($len); - } - else { - my $res = ''; - for (0..$len-1) { - vec($res, $_, 8) = int rand 256; - } - - return $res; - } -} - -sub session_passwd { - my ($c, $passwd, $challenge) = @_; - my $secAlg = $c->config->{session}{secure}; - - warn_crypt($c); - - if (defined $passwd) { # set - if ($secAlg eq 'cram') { - $c->session(S_PASSWD() => $passwd, challenge => $challenge); - } - elsif ($secAlg eq 's3d') { - unless ($passwd) { - $c->s3d(S_PASSWD, ''); - delete $c->session->{S_OTP_S3D_PW()}; - return; - } - die "'$passwd' contains invalid character \\n" if $passwd =~ /\n/; - if (length $passwd < 20) { - $passwd .= "\n" . ' ' x (20 - length($passwd) - 1); - } - my $passwd_utf8 = encode('UTF-8', $passwd); - my $rand_bytes = _rand_data(length $passwd_utf8); - $c->s3d(S_PASSWD, b64_encode($passwd_utf8 ^ $rand_bytes, '')); - $c->session(S_OTP_S3D_PW, b64_encode($rand_bytes, '')); - } - else { - $c->session(S_PASSWD() => $passwd); - } - } - else { # get - if ($secAlg eq 'cram') { - wantarray or carp "you forgot the challenge"; - return ($c->session(S_PASSWD), $c->session('challenge')); - } - elsif ($secAlg eq 's3d') { - my $pw = b64_decode($c->s3d(S_PASSWD) || ''); - my $otp = b64_decode($c->session(S_OTP_S3D_PW) || ''); - my ($res) = split "\n", decode('UTF-8', $pw ^ $otp), 2; - return $res; - } - else { - return $c->session(S_PASSWD); - } - } -} - -sub warn_crypt { - my $c = shift; - - state $once = 0; - - 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; - } -} - - -### pagination - -sub _clamp { - my ($x, $y, $z) = @_; - - die '!($x <= $z)' unless $x <= $z; - - if ($x <= $y && $y <= $z) { - return $y; - } - - return $x if ($y < $x); - return $z if ($z < $y); -} - -sub _paginate { - my %args = @_; - - my $first_item = $args{first_item}; - my $page_size = $args{page_size}; - my $total_items = $args{total_items}; - - my $current_page = ceil($first_item/$page_size); - my $total_pages = ceil($total_items/$page_size); - - my $page = sub { - my $page_ = shift; - return [0, 0] unless $total_items; - $page_ = _clamp(0, $page_, $total_pages-1); - [_clamp(0, $page_*$page_size, $total_items-1), _clamp(0, ($page_+1)*$page_size, $total_items)] - }; - - my $ret = { - total_items => $total_items, - page_size => $page_size, - - total_pages => $total_pages, - 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 { - my $c = shift; - my ($count) = @_; - - my $v = $c->validation; - my $start = $v->optional('start')->num(0, undef)->param // 0; - 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, max($count, 0)); - - $c->stash(pgn => _paginate( - first_item => int($start/$psize)*$psize, - page_size => $psize, - total_items => $count, - )); - - return $start, $end; -} - - -### registering - -sub register { - my ($self, $app, $conf) = @_; - $conf //= {}; - - if (ref $conf->{import} eq 'ARRAY' and my @import = @{ $conf->{import} }) { - my sub contains { any { $_[0] eq $_ } @import } - - # selective import - $app->helper(print_sizes10 => sub { shift; print_sizes10(@_) }) - if contains 'print_sizes10'; - $app->helper(parse_iso_date => sub { shift; parse_iso_date(@_) }) - if contains 'parse_iso_date'; - $app->helper(print_sizes2 => sub { shift; print_sizes2(@_) }) - if contains 'print_sizes2'; - $app->helper(mime_render => \&mime_render) - if contains 'mime_render'; - $app->helper(session_passwd => \&session_passwd) - if contains 'session_passwd'; - $app->helper(paginate => \&paginate) - if contains 'paginate'; - $app->validator->add_check(mail_line => \&mail_line) - if contains 'mail_line'; - $app->validator->add_filter(non_empty_ul => \&filter_empty_upload) - if contains 'non_empty_ul'; - } - 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(mime_render => \&mime_render); - $app->helper(session_passwd => \&session_passwd); - $app->helper(paginate => \&paginate); - - $app->validator->add_check(mail_line => \&mail_line); - - $app->validator->add_filter(non_empty_ul => \&filter_empty_upload); - } - else { - die 'unkown value for "import"' - } -} - - -1 - -__END__ - -=encoding utf-8 - -=head1 NAME - -Helper - Functions used as helpers in controller and templates and additional validator checks and filters - -=head1 SYNOPSIS - - use Mojo::Base 'Mojolicious'; - - sub startup($self) { - $app->plugin('Helper'); - } - -=head1 DESCRIPTION - -L<JWebmail::Helper> provides useful helper functions and validator cheks and filter for -L<JWebmail::Controller::All> and various templates. - -=head1 HELPERS - -=head2 mail_line - -A check for validator used in mail headers for fields containing email addresses. - - $app->validator->add_check(mail_line => \&JWebmail::Plugin::Helper::mail_line); - - my $v = $c->validation; - $v->required('to', 'not_empty')->check('mail_line'); - -=head2 filter_empty_upload - -A filter for validator used to filter out empty uploads. - - $app->validator->add_filter(non_empty_ul => \&JWebmail::Plugin::Helper::filter_empty_upload); - - my $v = $c->validation; - $v->required('file_upload', 'non_empty_ul'); - -=head2 print_sizes10 - -A helper for templates used to format byte sizes. - - $app->helper(print_sizes10 => sub { shift; JWebmail::Plugin::Helper::print_sizes10(@_) }); - - %= print_sizes10 12345 # => 12 kB - -=head2 print_sizes2 - -A helper for templates used to format byte sizes. - - %= print_sizes10 12345 # => 12 KiB - -This is not registered by default. - -=head2 paginate - -A helper for calculating page bounds. - -Takes the total number of items as argument. - -Reads in 'start' and 'page_size' query arguments. -start is 0 based. - -Returns the calculated start and end points as 0 based inclusive range. - -Sets the stash values (all 1 based inclusive): - - first_item - last_item - total_items - page_size - total_pages - current_page - first_page - prev_page - next_page - last_page - -=head2 session_passwd - -A helper used to set and get the session password. The behavior can be altered by -setting the config variable C<< session => {secure => 's3d'} >>. - - $app->helper(session_passwd => \&JWebmail::Plugin::Helper::session_passwd); - - $c->session_passwd('s3cret'); - -Currently the following modes are supported: - -=over 6 - -=item none - -The password is plainly stored in session cookie. -The cookie is stored on the client side and send with every request. - -=item cram - -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 - -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 - -=head1 DEPENDENCIES - -Mojolicious and recommended Crypt::URandom. - -=head1 SEE ALSO - -L<JWebmail> - -=head1 NOTICE - -This package is part of JWebmail. diff --git a/lib/JWebmail/Plugin/Paginate.pm b/lib/JWebmail/Plugin/Paginate.pm new file mode 100644 index 0000000..1a48ed3 --- /dev/null +++ b/lib/JWebmail/Plugin/Paginate.pm @@ -0,0 +1,145 @@ +package JWebmail::Plugin::Paginate; + +use Mojo::Base Mojolicious::Plugin; + +use List::Util qw(any max); +use POSIX 'ceil'; + + +sub _clamp { + my ($x, $y, $z) = @_; + + die '!($x <= $z)' unless $x <= $z; + + if ($x <= $y && $y <= $z) { + return $y; + } + + return $x if ($y < $x); + return $z if ($z < $y); +} + +sub _paginate { + my %args = @_; + + my $first_item = $args{first_item}; + my $page_size = $args{page_size}; + my $total_items = $args{total_items}; + + my $current_page = ceil($first_item/$page_size); + my $total_pages = ceil($total_items/$page_size); + + my $page = sub { + my $page_ = shift; + return [0, 0] unless $total_items; + $page_ = _clamp(0, $page_, $total_pages-1); + [_clamp(0, $page_*$page_size, $total_items-1), _clamp(0, ($page_+1)*$page_size, $total_items)] + }; + + my $ret = { + total_items => $total_items, + page_size => $page_size, + + total_pages => $total_pages, + 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 { + my $c = shift; + my ($count) = @_; + + my $v = $c->validation; + my $start = $v->optional('start')->num(0, undef)->param // 0; + 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, max($count, 0)); + + $c->stash(pgn => _paginate( + first_item => int($start/$psize)*$psize, + page_size => $psize, + total_items => $count, + )); + + return $start, $end; +} + + +sub register { + my ($self, $app, $conf) = @_; + $conf //= {}; + + $app->helper(paginate => \&paginate); +} + + +1 + +__END__ + +=encoding utf-8 + +=head1 NAME + +JWebmail::Plugin::Paginate + +=head1 SYNOPSIS + + sub my_route { + my ($c) = @_; + + $c->stash($c->paginate(1234)); + } + +=head1 DESCRIPTION + +L<JWebmail::Helper> provides useful helper functions and validator cheks and filter for +L<JWebmail::Controller::All> and various templates. + +=head1 HELPERS + +=head2 paginate + +A helper for calculating page bounds. + +Takes the total number of items as argument. + +Reads in 'start' and 'page_size' query arguments. +start is 0 based. + +Returns the calculated start and end points as 0 based inclusive range. + +Sets the stash values (all 1 based inclusive): + + first_item + last_item + total_items + page_size + total_pages + current_page + first_page + prev_page + next_page + last_page + +=head1 SEE ALSO + +L<JWebmail> + +=head1 NOTICE + +This package is part of JWebmail. diff --git a/lib/JWebmail/Plugin/RenderMail.pm b/lib/JWebmail/View/RenderMail.pm index 22edbbd..07f356c 100644 --- a/lib/JWebmail/Plugin/RenderMail.pm +++ b/lib/JWebmail/View/RenderMail.pm @@ -1,13 +1,16 @@ -package JWebmail::Plugin::RenderMail; +package JWebmail::View::RenderMail; -use Mojo::Base 'Mojolicious::Plugin'; +use Mojo::Base -base; use Mojo::ByteStream; use Mojo::Util 'xml_escape'; +has 'c'; + + sub render_text_plain { - my ($_c, $_subtype, $content, $_path) = @_; + my ($_self, $_subtype, $content, $_path) = @_; $content = xml_escape($content); $content =~ s/\n/<br>/g; @@ -16,16 +19,22 @@ sub render_text_plain { } sub render_text_html { - my ($c, $_subtype, $_content, $path) = @_; + my ($self, $_subtype, $_content, $path) = @_; - my $url = $c->url_for('raw', id => $c->stash('id')); + my $url = $self->c->url_for('raw', id => $self->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_image { + my ($_self, $subtype, $content, $_path) = @_; + + return qq'<img src="data:image/$subtype;base64,$content" />'; +} + sub render_multipart_alternative { - my ($c, $_subtype, $content, $path) = @_; + my ($self, $_subtype, $content, $path) = @_; my $parts = $content->{parts}; my $R = qq'<div class="jwm-mail-body jwm-mail-body-multipart-alternative"\n>'; @@ -34,7 +43,7 @@ sub render_multipart_alternative { for (reverse @$parts) { if (!$end) { - my $x = mime_render($c, to_mime_types($_->{head}), $_->{body}, [@$path, $#$parts-$i]); + my $x = $self->mime_render(to_mime_types($_->{head}), $_->{body}, [@$path, $#$parts-$i]); if ($x) { $R .= $x; $end = 1; @@ -45,7 +54,7 @@ sub render_multipart_alternative { $R .= '<summary>'; $R .= to_mime_type($_->{head}); $R .= "</summary>\n"; - $R .= mime_render($c, to_mime_types($_->{head}), $_->{body}, [@$path, $#$parts-$i]); + $R .= $self->mime_render(to_mime_types($_->{head}), $_->{body}, [@$path, $#$parts-$i]); $R .= "</details>\n"; } ++$i; @@ -54,7 +63,7 @@ sub render_multipart_alternative { } sub render_multipart { - my ($c, $_subtype, $content, $path) = @_; + my ($self, $_subtype, $content, $path) = @_; my $parts = $content->{parts}; my $R = qq'<div class="jwm-mail-body jwm-mail-body-multipart"\n>'; @@ -65,11 +74,11 @@ sub render_multipart { || lc $_->{head}{content_disposition} eq 'none' || lc $_->{head}{content_disposition} eq 'inline') { - $R .= mime_render($c, to_mime_types($_->{head}), $_->{body}, [@$path, $i]); + $R .= $self->mime_render(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)), (download => $_->{head}{filename}) => sub { + $R .= $self->c->link_to($self->c->url_for(raw => id => $self->c->stash('id'))->query(path => join('.', @$path, $i)), (download => $_->{head}{filename}) => sub { 'Attachment ' . xml_escape($_->{head}{filename}) . ' of type ' . to_mime_type($_->{head}); }); $R .= "</p>\n"; @@ -84,12 +93,12 @@ sub render_multipart { } sub _format_header { - my ($c, $category, $value) = @_; + my ($self, $category, $value) = @_; my $R = ''; if (ref $value eq 'ARRAY' && $value->@*) { - $R .= '<dt>' . xml_escape(uc $c->l($category)) . "</dt>\n"; + $R .= '<dt>' . xml_escape(uc $self->c->l($category)) . "</dt>\n"; for ($value->@*) { $R .= '<dd>'; $R .= xml_escape($_->{display_name} ? qq("$_->{display_name}" <$_->{address}>) : "$_->{address}"); @@ -100,28 +109,28 @@ sub _format_header { } sub render_message { - my ($c, $subtype, $msg, $path) = @_; + my ($self, $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 .= '<dt>' . xml_escape(uc $self->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 .= $self->_format_header(from => $msg->{head}{from}); + $R .= $self->_format_header(to => $msg->{head}{to}); + $R .= $self->_format_header(cc => $msg->{head}{cc}); + $R .= $self->_format_header(bcc => $msg->{head}{bcc}); + $R .= '<dt>' . xml_escape(uc $self->c->l('date')) . '</dt>'; $R .= '<dd>' . xml_escape($msg->{head}{date}) . "</dd>\n"; - $R .= '<dt>' . xml_escape(uc $c->l('content-type')) . '</dt>'; + $R .= '<dt>' . xml_escape(uc $self->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]); + $R .= $self->mime_render(to_mime_types($msg->{head}{mime}), $msg->{body}, [@$path, 0]); return $R . "</div>\n"; } @@ -132,10 +141,11 @@ our %MIME_Render_Subs = ( 'multipart/alternative' => \&render_multipart_alternative, 'multipart' => \&render_multipart, 'message' => \&render_message, + 'image' => \&render_image, ); sub mime_render { - my ($c, $maintype, $subtype, $content, $path) = @_; + my ($self, $maintype, $subtype, $content, $path) = @_; my $renderer = $MIME_Render_Subs{"$maintype/$subtype"} || $MIME_Render_Subs{$maintype}; @@ -143,7 +153,7 @@ sub mime_render { return "<p>Unsupported MIME type of <code>$maintype/$subtype</code>.</p>\n"; } - return $renderer->($c, $subtype, $content, $path); + return $renderer->($self, $subtype, $content, $path); } @@ -151,12 +161,10 @@ sub to_mime_type { lc xml_escape("$_[0]->{content_maintype}/$_[0]->{content_sub sub to_mime_types { return xml_escape($_[0]->{content_maintype}), xml_escape($_[0]->{content_subtype}) } -sub register { - my ($self, $app, $conf) = @_; - $conf //= {}; +sub format_mail { + my ($self, $mail) = @_; - $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(@_) }); + return Mojo::ByteStream->new($self->mime_render('message', 'rfc822', $mail, [])); } 1 @@ -167,16 +175,17 @@ __END__ =head1 NAME -JWebmail::Plugin::RenderMail - Does the heavy lifting of converting an E-Mail to HTML - -=head1 HELPERS +JWebmail::View::RenderMail - Does the heavy lifting of converting an E-Mail to HTML -=head2 render_mail.format_mail - -Renders a mail to html recursively. +=head1 FUNCTIONS =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. +=head1 METHODS + +=head2 format_mail + +Renders a mail to html recursively. diff --git a/lib/JWebmail/View/Webmail.pm b/lib/JWebmail/View/Webmail.pm new file mode 100644 index 0000000..464c97e --- /dev/null +++ b/lib/JWebmail/View/Webmail.pm @@ -0,0 +1,88 @@ +package JWebmail::View::Webmail; + +use Mojo::Base -base; + +use POSIX qw(floor round log); + + +### template formatting functions + +sub print_sizes10 { + shift; + my $var = shift || return '0 Byte'; + + my $i = floor(((log($var)/log(10))+1e-5) / 3); + my $expo = $i * 3; + + my @PREFIX; + $PREFIX[0] = 'Byte'; + $PREFIX[1] = 'kByte'; + $PREFIX[2] = 'MByte'; + $PREFIX[3] = 'GByte'; + $PREFIX[4] = 'TByte'; + $PREFIX[5] = 'PByte'; + + return sprintf('%.0f %s', $var / (10**$expo), $PREFIX[$i]); +} + +sub print_sizes2 { + shift; + my $var = shift || return '0 Byte'; + + my $i = floor(((log($var)/log(2))+1e-5) / 10); + my $expo = $i * 10; + my %PREFIX = ( + 0 => 'Byte', + 1 => 'KiByte', + 2 => 'MiByte', + 3 => 'GiByte', + 4 => 'TiByte', + 5 => 'PiByte', + ); + my $pref = $PREFIX{$i}; + return round($var / (2**$expo)) . " $pref"; +} + +my sub dgt { "([[:digit:]]{$_[0]})" } + +sub parse_iso_date { + shift; + state $rx = do { my $re = dgt(4).'-'.dgt(2).'-'.dgt(2).'T'.dgt(2).':'.dgt(2).':'.dgt(2); qr/$re/a }; + my @d = shift =~ /$rx/; + if (@d != 6) { + # TODO + warn "issue when parsing date"; + } + return { + year => $d[0], + month => $d[1], + mday => $d[2], + hour => $d[3], + min => $d[4], + sec => $d[5], + }; +} + +1 + +__END__ + +=head1 VIEW METHODS + +=head2 print_sizes10 + +A helper for templates used to format byte sizes. + + $app->helper(print_sizes10 => sub { shift; JWebmail::Plugin::Helper::print_sizes10(@_) }); + + %= print_sizes10 12345 # => 12 kB + +=head2 print_sizes2 + +A helper for templates used to format byte sizes. + + %= print_sizes10 12345 # => 12 KiB + +This is not registered by default. + +=head2 parse_iso_date diff --git a/t/Helper.t b/t/Pagination.t index 4578594..e06df03 100644 --- a/t/Helper.t +++ b/t/Pagination.t @@ -1,89 +1,19 @@ use v5.22; use warnings; +use strict; use utf8; use Test::More; -use Encode 'decode'; -use MIME::Words 'decode_mimewords'; - -use JWebmail::Plugin::Helper; - - -subtest 'print_size10' => sub { - my %TESTS = ( - 1 => '1 Byte', - 10 => '10 Byte', - 100 => '100 Byte', - 1000 => '1 kByte', - 10000 => '10 kByte', - 100000 => '100 kByte', - 1000000 => '1 MByte', - 10 * 10**6 => '10 MByte', - 10 * 2**20 => '10 MByte', - 800 => '800 Byte', - 9999 => '10 kByte', - 9500 => '10 kByte', - 1024 => '1 kByte', - 1023 => '1 kByte', - ); - - plan tests => scalar keys %TESTS; - - while (my ($input, $want) = each %TESTS) { - is(JWebmail::Plugin::Helper::print_sizes10($input), $want); - } -}; - - -subtest 'vaild_mail_line' => sub { - my %TESTS = ( - 'abc@example.com' => 1, - 'ABC Ex <abc@example.com>' => 1, - '"ABC Ex" <abc@example.com>' => 1, - '"A@B.V Ex" <abc@example.com>' => 1, - '"A@B.V Ex\"" <abc@example.com>' => 1, - 'ABC Ex abc@example.com' => 0, - ); - - plan tests => scalar keys %TESTS; - - while (my ($input, $want) = each %TESTS) { - cmp_ok(JWebmail::Plugin::Helper->mail_line('', $input), '!=', $want); - } -}; - - -subtest 'mime_word_decode' => sub { - my $input = "=?utf-8?Q?Jannis=20wir=20vermissen=20dich!=20Komm=20zur=C3=BCck=20und=20spare=20mit=20uns=20beim=20shoppen=20deiner=20Lieblingsmarken?="; - my $want = "Jannis wir vermissen dich! Komm zurück und spare mit uns beim shoppen deiner Lieblingsmarken"; - my $got = scalar decode_mimewords $input; - - isnt $want, $got; - is $want, _to_perl_enc(decode_mimewords $input); - is $want, decode('MIME-Header', $input); - - done_testing 3; -}; - -sub _to_perl_enc { - my $out = ''; - for (@_) { - if ($_->[1]) { - $out .= decode($_->[1], $_->[0]); - } - else { - $out .= $_->[0]; - } - } - return $out; -} +use JWebmail::Plugin::Paginate; subtest 'pagination' => sub { my %res; - %res = JWebmail::Plugin::Helper::_paginate(first_item => 0, page_size => 10, total_items => 55)->%*; + my $p = \&JWebmail::Plugin::Paginate::_paginate; + + %res = $p->(first_item => 0, page_size => 10, total_items => 55)->%*; is $res{first_item}, 0; is $res{last_item}, 9; @@ -98,7 +28,7 @@ subtest 'pagination' => sub { 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)->%*; + %res = $p->(first_item => 10, page_size => 10, total_items => 55)->%*; is $res{first_item}, 10; is $res{last_item}, 19; @@ -113,7 +43,7 @@ subtest 'pagination' => sub { 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)->%*; + %res = $p->(first_item => 20, page_size => 10, total_items => 55)->%*; is $res{first_item}, 20; is $res{last_item}, 29; @@ -128,7 +58,7 @@ subtest 'pagination' => sub { 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)->%*; + %res = $p->(first_item => 50, page_size => 10, total_items => 55)->%*; is $res{first_item}, 50; is $res{last_item}, 54; @@ -143,7 +73,7 @@ subtest 'pagination' => sub { 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)->%*; + %res = $p->(first_item => 0, page_size => 10, total_items => 0)->%*; ok !defined $res{first_item}; ok !defined $res{last_item}; @@ -161,7 +91,7 @@ subtest 'pagination' => sub { SKIP: { skip 'The first_item does not align with page boundaries and behaiviour is not specified.'; - %res = JWebmail::Plugin::Helper::_paginate(first_item => 19, page_size => 10, total_items => 55)->%*; + %res = $p->(first_item => 19, page_size => 10, total_items => 55)->%*; is $res{first_item}, 20; is $res{last_item}, 29; @@ -180,5 +110,4 @@ subtest 'pagination' => sub { done_testing; }; - done_testing; diff --git a/t/ViewWebmail.t b/t/ViewWebmail.t new file mode 100644 index 0000000..b1dd75f --- /dev/null +++ b/t/ViewWebmail.t @@ -0,0 +1,84 @@ +use v5.22; +use warnings; +use strict; +use utf8; + +use Test::More; + +use Encode 'decode'; +use MIME::Words 'decode_mimewords'; + +use JWebmail; +use JWebmail::View::Webmail; + + +subtest 'print_size10' => sub { + my %TESTS = ( + 1 => '1 Byte', + 10 => '10 Byte', + 100 => '100 Byte', + 1000 => '1 kByte', + 10000 => '10 kByte', + 100000 => '100 kByte', + 1000000 => '1 MByte', + 10 * 10**6 => '10 MByte', + 10 * 2**20 => '10 MByte', + 800 => '800 Byte', + 9999 => '10 kByte', + 9500 => '10 kByte', + 1024 => '1 kByte', + 1023 => '1 kByte', + ); + + plan tests => scalar keys %TESTS; + + while (my ($input, $want) = each %TESTS) { + is(JWebmail::View::Webmail->print_sizes10($input), $want); + } +}; + + +subtest 'vaild_mail_line' => sub { + my %TESTS = ( + 'abc@example.com' => 1, + 'ABC Ex <abc@example.com>' => 1, + '"ABC Ex" <abc@example.com>' => 1, + '"A@B.V Ex" <abc@example.com>' => 1, + '"A@B.V Ex\"" <abc@example.com>' => 1, + 'ABC Ex abc@example.com' => 0, + ); + + plan tests => scalar keys %TESTS; + + while (my ($input, $want) = each %TESTS) { + cmp_ok(JWebmail->_mail_line('', $input), '!=', $want); + } +}; + + +subtest 'mime_word_decode' => sub { + my $input = "=?utf-8?Q?Jannis_wir_vermissen_dich!=20Komm=20zur=C3=BCck=20und=20spare=20mit=20uns=20beim=20shoppen=20deiner=20Lieblingsmarken?="; + my $want = "Jannis wir vermissen dich! Komm zurück und spare mit uns beim shoppen deiner Lieblingsmarken"; + my $got = scalar decode_mimewords $input; + + isnt $want, $got; + is $want, _to_perl_enc(decode_mimewords $input); + is $want, decode('MIME-Header', $input); + + done_testing 3; +}; + +sub _to_perl_enc { + my $out = ''; + for (@_) { + if ($_->[1]) { + $out .= decode($_->[1], $_->[0]); + } + else { + $out .= $_->[0]; + } + } + return $out; +} + +done_testing; diff --git a/templates/displayheaders/_folders.html.ep b/templates/displayheaders/_folders.html.ep index be5bdd9..b341768 100644 --- a/templates/displayheaders/_folders.html.ep +++ b/templates/displayheaders/_folders.html.ep @@ -23,7 +23,7 @@ <p class="pure-u-1-1 pure-u-md-1-2"> <%= l('[_1] of [_2] messages', $pgn->{this_page}[1] - $pgn->{this_page}[0], $pgn->{total_items}) %>\ <%= l(', [_1] new', $total_new_mails) if $total_new_mails > 0 =%> - <%= l(' - mailbox size: [_1]', print_sizes10 $total_size) if $total_size %> + <%= l(' - mailbox size: [_1]', $v->print_sizes10($total_size)) if $total_size %> </p> </div> diff --git a/templates/displayheaders/_main_table.html.ep b/templates/displayheaders/_main_table.html.ep index 5430c15..06bbbfc 100644 --- a/templates/displayheaders/_main_table.html.ep +++ b/templates/displayheaders/_main_table.html.ep @@ -81,7 +81,7 @@ --> <div class="pure-u-1 pure-u-md-4-24"> - % my $date = parse_iso_date $msg->{head}{date}; + % my $date = $v->parse_iso_date($msg->{head}{date}); %= join('/', $date->{mday}, $date->{month}, $date->{year}) . " $date->{hour}:$date->{min}"; </div> @@ -98,7 +98,7 @@ </td> <td class="hide-small" style="text-align: right; white-space: nowrap"> - %= print_sizes10 $msg->{byte_size}; + %= $v->print_sizes10($msg->{byte_size}); </td> <td> diff --git a/templates/not_found_.html.ep b/templates/not_found.production.html.ep index b96e46e..b96e46e 100644 --- a/templates/not_found_.html.ep +++ b/templates/not_found.production.html.ep diff --git a/templates/webmail/readmail.html.ep b/templates/webmail/readmail.html.ep index 5bdc27e..529bbe6 100644 --- a/templates/webmail/readmail.html.ep +++ b/templates/webmail/readmail.html.ep @@ -5,7 +5,7 @@ <h1>Read Mail</h1> -%= $c->render_mail->format_mail($msg) +%= $v->format_mail($msg) <nav> <a href="javascript:history.back()" class="pure-button"> |