The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Mail::Decency::Core::SessionItem::ContentFilter;

use Moose;
extends qw/
    Mail::Decency::Core::SessionItem
/;

use version 0.74; our $VERSION = qv( "v0.1.4" );

use MIME::Parser;
use IO::File;
use YAML;
use Data::Dumper;

=head1 NAME

Mail::Decency::Core::SessionItem::ContentFilter

=head1 DESCRIPTION

The id attribute is the current QUEUE ID

=head1 CLASS ATTRIBUTES

=head2 file

The file (in the spool folder, absolute path)

=cut

has file => ( is => 'ro', isa => "Str", required => 1, trigger => \&_init_file );

=head2 store

YAML file containing the current info

=cut

has store => ( is => 'rw', isa => "Str" );

=head2 file_size

Size of the current file (id)

=cut

has file_size => ( is => 'rw', isa => "Int", default => 0 );

=head2 virus

String containg info (name) of the virus

=cut

has virus => ( is => 'rw', isa => "Str" );

=head2 next_id

If set, we now of the next queue id

=cut

has next_id => ( is => 'rw', isa => "Str" );

=head2 prev_id

If set, we now of the previous queue id

=cut

has prev_id => ( is => 'rw', isa => "Str" );

=head2 mime_output_dir

The directory where mime files are to be output (from content filter)

=cut

has mime_output_dir => ( is => 'rw', isa => "Str", required => 1 );

=head2 mime

Is a MIME::Entity object representing the current mail

=cut

has mime => ( is => 'rw', isa => "MIME::Entity" );

=head2 mime_filer

The filer used for cleanup

=cut

has mime_filer => ( is => 'rw', isa => "MIME::Parser::FileUnder" );

=head2 mime_fh

File handle for mime file

=cut

has mime_fh => ( is => 'rw', isa => "IO::File" );

=head2 verify_key

Instance of L<Crypt::OpenSSL::RSA> representing the forward sign key

=cut

has verify_key => ( is => 'rw', isa => 'Crypt::OpenSSL::RSA', predicate => 'can_verify' );


=head2 verify_ttl

TTL for validity of signatures in seconds

=cut

has verify_ttl => ( is => 'rw', isa => 'Int', predicate => 'has_verify_ttl' );


=head2 update_store 

Write store YAML file

=cut

sub update_store {
    my ( $self ) = @_;
    open my $fh, '>', $self->store
        or die "Cannot open store file ". $self->store. " for write: $!";
    my %create = ();
    $create{ from } = $self->from if $self->from;
    $create{ to } = $self->to if $self->to;
    print $fh YAML::Dump( {
        file => $self->file,
        size => $self->file_size,
        %create
    } );
    close $fh;
}


=head2 update_from_policy_cache 

Update session from cached policy session

=cut

sub update_from_policy_cache {
    my ( $self, $hash_ref ) = @_;
    
    # update spam score
    $self->spam_score( $self->spam_score + $hash_ref->{ spam_score } )
        if $hash_ref->{ spam_score };
    
    # update spam details
    push @{ $self->spam_details }, @{ $hash_ref->{ spam_details } }
        if $hash_ref->{ spam_details };
    
    # update spam details
    if ( $hash_ref->{ flags } ) {
        $self->set_flag( $_ ) for keys %{ $hash_ref->{ flags } };
    }
    
    return;
}


=head2 update_from_cache 

Update session from cached session

=cut

sub update_from_cache {
    my ( $self, $hash_ref ) = @_;
    
    $self->update_from_policy_cache( $hash_ref );
    
    $self->virus( join( "; ", $self->virus, $hash_ref->{ virus } ) )
        if $hash_ref->{ virus };
    
    foreach my $id( qw/ next_id prev_id / ) {
        $self->$id( $hash_ref->{ $id } )
            if ! $self->$id && $hash_ref->{ $id };
    }
    
    return;
}


=head2 write_mime 

Update the file ($self->file) from mime .. should be performed after
mime manipulations

=cut

sub write_mime {
    my ( $self ) = @_;
    
    # get mime object
    my $mime = $self->mime;
    
    # resync file size
    $mime->sync_headers( Length => 'COMPUTE' );
    
    # store backup fore failure recovery
    my $tmp_name = $self->file. ".$$.". time();
    rename( $self->file, $tmp_name );
    
    # write back to file
    eval {
        unlink( $self->file );
        open my $fh, '>', $self->file;
        $mime->print( $fh );
        close $fh;
    };
    
    # restore backup on error
    if ( $@ ) {
        rename( $tmp_name, $self->file );
        return 0;
    }
    else {
        unlink( $tmp_name );
    }
    
    return 1;
}


=head2 for_cache

returns data formatted for cache

=cut

sub for_cache {
    my ( $self ) = @_;
    
    return {
        spam_score   => $self->spam_score,
        spam_details => $self->spam_details,
        virus        => $self->virus,
        queue_id     => $self->id,
        next_id      => $self->next_id,
        prev_id      => $self->prev_id
    };
}


=head2 cleanup

Called at the end of the session.. removes all temp files and the mail file

=cut

sub cleanup {
    my ( $self ) = @_;
    
    # close mime handle
    eval { $self->mime_fh->close }; # do silent, don't care
    
    # clear mime
    $self->mime_filer->purge;
    
    # remove store file
    unlink $self->store
        if $self->store && -f $self->store;
    
    # remove store file
    unlink $self->file
        if $self->file && -f $self->file;
    
    $self->unset;
    
    return ;
}


=head2 retreive_policy_scoring

=cut

sub retreive_policy_scoring {
    my ( $self, $accept_scoring ) = @_;
    
    # having decency instance (from policy) ?
    my @instance = map {
        chomp;
        my ( $instance, $signature, $weight, $timestamp, $flags, @info ) = split( /\|/, $_ );
        [ $instance, $signature, $weight, $timestamp, $flags, @info ];
    } $self->mime->head->get( 'X-Decency-Instance' );
    
    # remember wheter cleanup is required
    my $cleanup_instance = scalar @instance > 0;
    
    # using signed forwarded info ? (bother only if scoring from external is accepted!)
    if ( @instance && $accept_scoring && $self->can_verify ) {
        
        # get all valid instances
        @instance = grep {
            my ( $instance, $signature, $weight, $timestamp, $flags, @info ) = @$_;
            
            # verify instance
            my $ok = $self->verify_key->verify(
                join( "|", $signature, $weight, $timestamp, $flags, @info ),
                pack( "H*", $signature )
            );
            
            # valid ?
            $ok && $timestamp <= time() && ( ! $self->has_verify_ttl || $timestamp + $self->verify_ttl >= time() );
        } @instance;
    }
    
    # having any instances ?
    if ( @instance ) {
        
        # handle first instance
        #   this is the LATEST instance.. contains the FINAL score
        my $first_ref = shift @instance;
        my ( $instance, $keyword, $weight, $timestamp, $flags, @info ) = @$first_ref;
        
        # try read from cache
        #   if policy and content filter use the same cache, this will hit!
        my $cached = $self->cache->get( "POLICY-$instance" );
        if ( $cached ) {
            
            # remove policy finally from cache..
            #   there are no policy filters behind the content filter ..
            $self->cache->remove( "POLICY-$instance" );
            
            # add spam score, details
            $self->update_from_policy_cache( $cached );
        }
        
        # not from cache
        #   if policy server accepts scorings in the first place ..
        elsif ( $accept_scoring ) {
            
            # init for update ..
            #   only the first weight will be used, because it is the last
            #   policy weight and therfore the cumulated policy weight
            $cached= {
                spam_score   => $weight,
                spam_details => \@info,
                flags        => { map { ( $_ => 1 ) } split( /\s*,\s*/, $flags ) }
            };
            
            # get flags and info from older instances
            foreach my $older_instance( @instance ) {
                ( undef, undef, undef, undef, my $add_flags, my @add_info )
                    = split( /\|/, $instance );
                push @{ $cached->{ spam_details } }, @add_info;
                $cached->{ flags }->{ $_ } = 1 for split( /\s*,\s*/, $add_flags );
            }
            
            # add spam score, details
            $self->update_from_policy_cache( $cached );
        }
    }
    
    # cleanup instances ?
    if ( $cleanup_instance ) {
        $self->mime->head->delete( 'X-Decency-Instance' );
        $self->write_mime();
    }
}

=pod

PRIVATE METHODS

=pod

_init_file

Triggerd on file set

=cut

sub _init_file {
    my ( $self ) = @_;
    
    die "Cannot access file '". $self->file. "'" unless -f $self->file;
    $self->file_size( -s $self->file );
    
    # store
    $self->store( $self->file. '.info' );
    my $has_store = 0;
    if ( -f $self->store ) {
        $has_store++;
        my $ref;
        eval {
            $ref = YAML::LoadFile( $self->store );
        };
        die "Error loading YAML file ". $self->store. ": $@" if $@;
        die "YAML file ". $self->store. " mal formatted, should be HASH, is '". ref( $ref ). "'"
            unless ref( $ref ) eq 'HASH';
        
        foreach my $attr( qw/ from to / ) {
            $self->$attr( $ref->{ $attr } ) unless $self->$attr;
        }
    }
    
    # setup mime
    my $parser = MIME::Parser->new;
    $parser->output_under( $self->mime_output_dir );
    $parser->decode_headers( 1 );
    
    # read from file and create
    my $orig_fh = IO::File->new( $self->file, 'r' )
        or die "Cannot open ". $self->file. " for read\n";
    
    eval {
        my $mime = $parser->parse( $orig_fh );
        $self->mime( $mime );
        $self->mime_filer( $parser->filer );
        $self->mime_fh( $orig_fh );
    };
    die "Error parsing MIME: $@\n" if $@;
    
    # extract relevant headers ..
    unless ( $self->to ) {
        my $to = "". ( $self->mime->head->get( 'Delivered-To' ) ||  $self->mime->head->get( 'To' )  || "" );
        if ( $to ) {
            if ( $to =~ /<([^>]+)>/ ) {
                $self->to( $1 );
            }
            else {
                $self->to( $to );
            }
        }
    }
    
    # extact from..
    unless ( $self->from ) {
        my $from = "". ( $self->mime->head->get( 'Return-Path' ) ||  $self->mime->head->get( 'From' ) || "" );
        if ( $from ) {
            if ( $from =~ /<([^>]+)>/ ) {
                $self->from( $1 );
            }
            else {
                $self->from( $from );
            }
        }
    }
    
    # write relevant info to store file
    $self->update_store() unless $has_store;
}

=head1 AUTHOR

Ulrich Kautz <uk@fortrabbit.de>

=head1 COPYRIGHT

Copyright (c) 2010 the L</AUTHOR> as listed above

=head1 LICENCSE

This library is free software and may be distributed under the same terms as perl itself.

=cut

1;