=head1 NAME
Mail::SpamAssassin::Message - decode, render, and hold an RFC-2822 message
=head1 DESCRIPTION
This module encapsulates an email message and allows access to the various MIME
message parts and message metadata.
The message structure, after initiating a parse() cycle, looks like this:
Message object, also top-level node in Message::Node tree
|
+---> Message::Node for other parts in MIME structure
| |---> [ more Message::Node parts ... ]
| [ others ... ]
|
+---> Message::Metadata object to hold metadata
=head1 PUBLIC METHODS
=over 4
=cut
package Mail::SpamAssassin::Message;
use strict;
use warnings;
use Mail::SpamAssassin;
use Mail::SpamAssassin::Message::Node;
use Mail::SpamAssassin::Message::Metadata;
use Mail::SpamAssassin::Constants qw(:sa);
use Mail::SpamAssassin::Logger;
use vars qw(@ISA);
@ISA = qw(Mail::SpamAssassin::Message::Node);
=item new()
Creates a Mail::SpamAssassin::Message object. Takes a hash reference
as a parameter. The used hash key/value pairs are as follows:
C<message> is either undef (which will use STDIN), a scalar of the
entire message, an array reference of the message with 1 line per array
element, and either a file glob or IO::File object which holds the entire
contents of the message.
Note: The message is expected to generally be in RFC 2822 format, optionally
including an mbox message separator line (the "From " line) as the first line.
C<parse_now> specifies whether or not to create the MIME tree
at object-creation time or later as necessary.
The I<parse_now> option, by default, is set to false (0).
This allows SpamAssassin to not have to generate the tree of
Mail::SpamAssassin::Message::Node objects and their related data if the
tree is not going to be used. This is handy, for instance, when running
C<spamassassin -d>, which only needs the pristine header and body which
is always handled when the object is created.
C<subparse> specifies how many MIME recursion levels should be parsed.
Defaults to 20.
=cut
my %MONTH = (jan => 1, feb => 2, mar => 3, apr => 4, may => 5, jun => 6,
jul => 7, aug => 8, sep => 9, oct => 10, nov => 11, dec => 12);
my @DAY_OF_WEEK = qw/Sun Mon Tue Wed Thu Fri Sat/ ;
sub new {
my $class = shift;
$class = ref($class) || $class;
my($opts) = @_;
my $message = $opts->{'message'} || \*STDIN;
my $parsenow = $opts->{'parsenow'} || 0;
my $normalize = $opts->{'normalize'} || 0;
my $subparse = defined $opts->{'subparse'} ? $opts->{'subparse'} : 20;
my $self = $class->SUPER::new({normalize=>$normalize});
$self->{tmpfiles} = [];
$self->{pristine_headers} = '';
$self->{pristine_body} = '';
$self->{mime_boundary_state} = {};
$self->{line_ending} = "\012";
bless($self,$class);
$self->{metadata} = Mail::SpamAssassin::Message::Metadata->new($self);
local $_;
my @message;
if (ref $message eq 'ARRAY') {
@message = @{$message};
}
elsif (ref $message eq 'GLOB' || ref $message eq 'IO::File') {
if (defined fileno $message) {
@message = <$message>;
}
}
elsif (ref $message) {
dbg("message: Input is a reference of unknown type!");
}
elsif (defined $message) {
@message = split ( /^/m, $message );
}
if (!@message) {
@message = ("\n");
} elsif ($message[0] =~ /^From\s/) {
$self->{'mbox_sep'} = shift @message;
} elsif ($message[0] =~ MBX_SEPARATOR) {
$_ = shift @message;
if (/([\s|\d]\d)-([a-zA-Z]{3})-(\d{4})\s(\d{2}):(\d{2}):(\d{2})/) {
my @arr = localtime(timelocal($6,$5,$4,$1,$MONTH{lc($2)}-1,$3));
my $address;
foreach (@message) {
if (/From:\s[^<]+<([^>]+)>/) {
$address = $1;
last;
} elsif (/From:\s([^<^>]+)/) {
$address = $1;
last;
}
}
$self->{'mbox_sep'} = "From $address $DAY_OF_WEEK[$arr[6]] $2 $1 $4:$5:$6 $3\n";
}
}
if (@message && $message[0] =~ /\015\012/) {
$self->{line_ending} = "\015\012";
dbg("message: line ending changed to CRLF");
}
my $header = '';
while ( my $current = shift @message ) {
unless ($self->{'missing_head_body_separator'}) {
$self->{'pristine_headers'} .= $current;
}
if ( $current =~ /^[ \t]/ ) {
if ($header) {
$header .= $current;
}
}
else {
if ($header) {
my ($key, $value) = split (/:/s, $header, 2);
if (defined $value) {
if (length($key) > MAX_HEADER_KEY_LENGTH) {
$key = substr($key, 0, MAX_HEADER_KEY_LENGTH);
$self->{'truncated_header'} = 1;
}
if (length($value) > MAX_HEADER_VALUE_LENGTH) {
$value = substr($value, 0, MAX_HEADER_VALUE_LENGTH);
$self->{'truncated_header'} = 1;
}
$self->header($key, $value);
}
}
$header = $current;
}
if ($header) {
if ($header =~ /^\r?$/) {
last;
}
else {
if (!@message || $message[0] !~ /^(?:[\041-\071\073-\176]+:|[ \t]|\r?$)/ || $message[0] =~ /^--/) {
$self->{'missing_head_body_separator'} = 1;
unshift(@message, "\n");
}
}
}
}
undef $header;
$self->{'pristine_body'} = join('', @message);
my $start;
for (my $cnt=$ $message[$cnt] =~ s/\015\012/\012/;
if ($message[$cnt] !~ /\S/) {
if (!defined $start) {
$start=$cnt;
}
next unless $cnt == 0;
}
if (defined $start) {
my $num = $start-$cnt;
if ($num > 10) {
splice @message, $cnt+2, $num-1;
}
undef $start;
}
}
my ($boundary);
($self->{'type'}, $boundary) = Mail::SpamAssassin::Util::parse_content_type($self->header('content-type'));
dbg("message: main message type: ".$self->{'type'});
$self->{'parse_queue'} = [ [ $self, $boundary, \@message, $subparse ] ];
if ($parsenow) {
$self->parse_body();
}
$self;
}
=item find_parts()
Used to search the tree for specific MIME parts. See
I<Mail::SpamAssassin::Message::Node> for more details.
=cut
sub find_parts {
my $self = shift;
$self->parse_body() if (exists $self->{'parse_queue'});
return $self->SUPER::find_parts(@_);
}
=item get_pristine_header()
Returns pristine headers of the message. If no specific header name
is given as a parameter (case-insensitive), then all headers will be
returned as a scalar, including the blank line at the end of the headers.
If called in an array context, an array will be returned with each
specific header in a different element. In a scalar context, the last
specific header is returned.
ie: If 'Subject' is specified as the header, and there are 2 Subject
headers in a message, the last/bottom one in the message is returned in
scalar context or both are returned in array context.
Note: the returned header will include the ending newline and any embedded
whitespace folding.
=cut
sub get_pristine_header {
my ($self, $hdr) = @_;
return $self->{pristine_headers} unless $hdr;
my(@ret) = $self->{pristine_headers} =~ /^\Q$hdr\E:[ \t]+(.*?\n(?![ \t]))/smgi;
if (@ret) {
if (wantarray) {
return map {
Mail::SpamAssassin::Util::taint_var($_);
} @ret;
} else {
return Mail::SpamAssassin::Util::taint_var($ret[-1]);
}
}
else {
return $self->get_header($hdr);
}
}
=item get_mbox_separator()
Returns the mbox separator found in the message, or undef if there
wasn't one.
=cut
sub get_mbox_separator {
return $_[0]->{mbox_sep};
}
=item get_body()
Returns an array of the pristine message body, one line per array element.
=cut
sub get_body {
my ($self) = @_;
my @ret = split(/^/m, $self->{pristine_body});
return \@ret;
}
=item get_pristine()
Returns a scalar of the entire pristine message.
=cut
sub get_pristine {
my ($self) = @_;
return $self->{pristine_headers} . $self->{pristine_body};
}
=item get_pristine_body()
Returns a scalar of the pristine message body.
=cut
sub get_pristine_body {
my ($self) = @_;
return $self->{pristine_body};
}
=item extract_message_metadata($permsgstatus)
=cut
sub extract_message_metadata {
my ($self, $permsgstatus) = @_;
if ($self->{already_extracted_metadata}) { return; }
$self->{already_extracted_metadata} = 1;
$self->{metadata}->extract ($self, $permsgstatus);
}
=item $str = get_metadata($hdr)
=cut
sub get_metadata {
my ($self, $hdr) = @_;
if (!$self->{metadata}) {
warn "metadata: oops! get_metadata() called after finish_metadata()"; return;
}
$self->{metadata}->{strings}->{$hdr};
}
=item put_metadata($hdr, $text)
=cut
sub put_metadata {
my ($self, $hdr, $text) = @_;
if (!$self->{metadata}) {
warn "metadata: oops! put_metadata() called after finish_metadata()"; return;
}
$self->{metadata}->{strings}->{$hdr} = $text;
}
=item delete_metadata($hdr)
=cut
sub delete_metadata {
my ($self, $hdr) = @_;
if (!$self->{metadata}) {
warn "metadata: oops! delete_metadata() called after finish_metadata()"; return;
}
delete $self->{metadata}->{strings}->{$hdr};
}
=item $str = get_all_metadata()
=cut
sub get_all_metadata {
my ($self) = @_;
if (!$self->{metadata}) {
warn "metadata: oops! get_all_metadata() called after finish_metadata()"; return;
}
my @ret = ();
foreach my $key (sort keys %{$self->{metadata}->{strings}}) {
push (@ret, "$key: " . $self->{metadata}->{strings}->{$key} . "\n");
}
return (wantarray ? @ret : join('', @ret));
}
=item finish_metadata()
Destroys the metadata for this message. Once a message has been
scanned fully, the metadata is no longer required. Destroying
this will free up some memory.
=cut
sub finish_metadata {
my ($self) = @_;
if (defined ($self->{metadata})) {
$self->{metadata}->finish();
delete $self->{metadata};
}
}
=item finish()
Clean up an object so that it can be destroyed.
=cut
sub finish {
my ($self) = @_;
$self->finish_metadata();
if ($self->{'tmpfiles'}) {
unlink @{$self->{'tmpfiles'}};
delete $self->{'tmpfiles'};
}
delete $self->{'mime_boundary_state'};
delete $self->{'mbox_sep'};
delete $self->{'normalize'};
delete $self->{'pristine_body'};
delete $self->{'pristine_headers'};
delete $self->{'line_ending'};
delete $self->{'missing_head_body_separator'};
my @toclean = ( $self );
while (my $part = shift @toclean) {
delete $part->{'headers'};
delete $part->{'raw_headers'};
delete $part->{'header_order'};
delete $part->{'raw'};
delete $part->{'decoded'};
delete $part->{'rendered'};
delete $part->{'visible_rendered'};
delete $part->{'invisible_rendered'};
delete $part->{'type'};
delete $part->{'rendered_type'};
if (exists $part->{'body_parts'}) {
push(@toclean, @{$part->{'body_parts'}});
delete $part->{'body_parts'};
}
}
}
sub DESTROY {
my $self = shift;
if ($self->{'tmpfiles'}) {
unlink @{$self->{'tmpfiles'}};
}
}
=item receive_date()
Return a time_t value with the received date of the current message,
or current time if received time couldn't be determined.
=cut
sub receive_date {
my($self) = @_;
return Mail::SpamAssassin::Util::receive_date(scalar $self->get_all_headers(0,1));
}
=back
=head1 PARSING METHODS, NON-PUBLIC
These methods take a RFC2822-esque formatted message and create a tree
with all of the MIME body parts included. Those parts will be decoded
as necessary, and text/html parts will be rendered into a standard text
format, suitable for use in SpamAssassin.
=over 4
=item parse_body()
parse_body() passes the body part that was passed in onto the
correct part parser, either _parse_multipart() for multipart/* parts,
or _parse_normal() for everything else. Multipart sections become the
root of sub-trees, while everything else becomes a leaf in the tree.
For multipart messages, the first call to parse_body() doesn't create a
new sub-tree and just uses the parent node to contain children. All other
calls to parse_body() will cause a new sub-tree root to be created and
children will exist underneath that root. (this is just so the tree
doesn't have a root node which points at the actual root node ...)
=cut
sub parse_body {
my($self) = @_;
return unless (exists $self->{'parse_queue'});
dbg("message: ---- MIME PARSER START ----");
while (my $toparse = shift @{$self->{'parse_queue'}}) {
if ( $toparse->[0]->{'type'} =~ /^multipart\//i && defined $toparse->[1] && ($toparse->[3] > 0)) {
$self->_parse_multipart($toparse);
}
else {
$self->_parse_normal($toparse);
if ($toparse->[0]->{'type'} =~ /^message\b/i && ($toparse->[3] > 0)) {
$toparse->[0]->decode(0);
if ($toparse->[0]->{'decoded'}) {
my $msg_obj = Mail::SpamAssassin::Message->new({
message => $toparse->[0]->{'decoded'},
parsenow => 0,
normalize => $self->{normalize},
subparse => $toparse->[3]-1,
});
$toparse->[0]->add_body_part($msg_obj);
push(@{$self->{'parse_queue'}}, @{$msg_obj->{'parse_queue'}});
delete $msg_obj->{'parse_queue'};
if (ref $toparse->[0]->{'raw'} eq 'GLOB') {
close ($toparse->[0]->{'raw'});
}
delete $toparse->[0]->{'raw'};
delete $toparse->[0]->{'decoded'};
}
}
}
}
dbg("message: ---- MIME PARSER END ----");
delete $self->{'parse_queue'};
}
=item _parse_multipart()
Generate a root node, and for each child part call parse_body()
to generate the tree.
=cut
sub _parse_multipart {
my($self, $toparse) = @_;
my ($msg, $boundary, $body, $subparse) = @{$toparse};
$msg->{'body_parts'} = [];
$subparse--;
dbg("message: parsing multipart, got boundary: ".(defined $boundary ? $boundary : ''));
if ( defined $boundary ) {
my $line;
my $tmp_line = @{$body};
for ($line=0; $line < $tmp_line; $line++) {
if ($body->[$line] =~ /^--\Q$boundary\E\s*$/) {
$self->{mime_boundary_state}->{$boundary} = 1;
last;
}
}
if ( $line < $tmp_line ) {
splice @{$body}, 0, $line+1;
}
}
my $part_msg = Mail::SpamAssassin::Message::Node->new({ normalize=>$self->{normalize} });
my $in_body = 0;
my $header;
my $part_array;
my $line_count = @{$body};
foreach ( @{$body} ) {
if ( --$line_count == 0 || (defined $boundary && /^--\Q$boundary\E(?:--)?\s*$/) ) {
my $line = $_;
if ($part_array) {
chomp( $part_array->[-1] ); splice @{$part_array}, -1 if ( $part_array->[-1] eq '' ); }
else {
$part_array = [];
}
my($p_boundary);
($part_msg->{'type'}, $p_boundary) = Mail::SpamAssassin::Util::parse_content_type($part_msg->header('content-type'));
$p_boundary ||= $boundary;
dbg("message: found part of type ".$part_msg->{'type'}.", boundary: ".(defined $p_boundary ? $p_boundary : ''));
push(@{$self->{'parse_queue'}}, [ $part_msg, $p_boundary, $part_array, $subparse ]);
$msg->add_body_part($part_msg);
if (defined $boundary && $line =~ /^--\Q${boundary}\E--\s*$/) {
$self->{mime_boundary_state}->{$boundary}--;
last;
}
$in_body = 0;
$part_msg = Mail::SpamAssassin::Message::Node->new({ normalize=>$self->{normalize} });
undef $part_array;
undef $header;
next;
}
if (!$in_body) {
if (m/^[\041-\071\073-\176]+:/) {
if ($header) {
my ( $key, $value ) = split ( /:\s*/, $header, 2 );
$part_msg->header( $key, $value );
}
$header = $_;
next;
}
elsif (/^[ \t]/) {
$header .= $_;
next;
}
else {
if ($header) {
my ( $key, $value ) = split ( /:\s*/, $header, 2 );
$part_msg->header( $key, $value );
}
$in_body = 1;
if (/^\r?$/) {
next;
}
else {
$self->{'missing_mime_head_body_separator'} = 1;
}
}
}
while (length ($_) > MAX_BODY_LINE_LENGTH) {
push (@{$part_array}, substr($_, 0, MAX_BODY_LINE_LENGTH)."\n");
substr($_, 0, MAX_BODY_LINE_LENGTH) = '';
}
push ( @{$part_array}, $_ );
}
}
=item _parse_normal()
Generate a leaf node and add it to the parent.
=cut
sub _parse_normal {
my($self, $toparse) = @_;
my ($msg, $boundary, $body) = @{$toparse};
dbg("message: parsing normal part");
my @ct = Mail::SpamAssassin::Util::parse_content_type($msg->header('content-type'));
$msg->{'type'} = ($ct[0] !~ m@^multipart/@i || defined $boundary ) ? $ct[0] : 'text/plain';
$msg->{'charset'} = $ct[2];
my $disp = $msg->header('content-disposition') || '';
if ($disp =~ /name="?([^\";]+)"?/i) {
$msg->{'name'} = $1;
}
elsif ($ct[3]) {
$msg->{'name'} = $ct[3];
}
$msg->{'boundary'} = $boundary;
if ($msg->{'type'} !~ m@^(?:text/(?:plain|html)$|message\b)@) {
my $filepath;
($filepath, $msg->{'raw'}) = Mail::SpamAssassin::Util::secure_tmpfile();
if ($filepath) {
push @{$self->{tmpfiles}}, $filepath;
$msg->{'raw'}->print(@{$body});
}
}
if (!exists $msg->{'raw'}) {
$msg->{'raw'} = $body;
}
}
sub get_rendered_body_text_array {
my ($self) = @_;
if (exists $self->{text_rendered}) { return $self->{text_rendered}; }
$self->{text_rendered} = [];
my @parts = $self->find_parts(qr/./,1);
return $self->{text_rendered} unless @parts;
my $html_needs_setting = !exists $self->{metadata}->{html};
my $text = $self->get_header ('subject') || "\n";
for(my $pt = 0 ; $pt <= $ my $p = $parts[$pt];
$text .= "\n";
my($type, $rnd) = $p->rendered(); if ( defined $rnd ) {
$text .= $rnd;
if ($html_needs_setting && $type eq 'text/html') {
$self->{metadata}->{html} = $p->{html_results};
}
}
}
$text =~ s/\n+\s*\n+/\f/gs; $text =~ tr/ \t\n\r\x0b\xa0/ /s; $text =~ tr/\f/\n/;
my @textary = split_into_array_of_short_lines ($text);
$self->{text_rendered} = \@textary;
return $self->{text_rendered};
}
sub get_visible_rendered_body_text_array {
my ($self) = @_;
if (exists $self->{text_visible_rendered}) {
return $self->{text_visible_rendered};
}
$self->{text_visible_rendered} = [];
my @parts = $self->find_parts(qr/./,1);
return $self->{text_visible_rendered} unless @parts;
my $html_needs_setting = !exists $self->{metadata}->{html};
my $text = $self->get_header ('subject') || "\n";
for(my $pt = 0 ; $pt <= $ my $p = $parts[$pt];
$text .= "\n";
my($type, $rnd) = $p->visible_rendered(); if ( defined $rnd ) {
$text .= $rnd;
if ($html_needs_setting && $type eq 'text/html') {
$self->{metadata}->{html} = $p->{html_results};
}
}
}
$text =~ s/\n+\s*\n+/\f/gs; $text =~ tr/ \t\n\r\x0b\xa0/ /s; $text =~ tr/\f/\n/;
my @textary = split_into_array_of_short_lines ($text);
$self->{text_visible_rendered} = \@textary;
return $self->{text_visible_rendered};
}
sub get_invisible_rendered_body_text_array {
my ($self) = @_;
if (exists $self->{text_invisible_rendered}) {
return $self->{text_invisible_rendered};
}
$self->{text_invisible_rendered} = [];
my @parts = $self->find_parts(qr/./,1);
return $self->{text_invisible_rendered} unless @parts;
my $html_needs_setting = !exists $self->{metadata}->{html};
my $text = '';
for(my $pt = 0 ; $pt <= $ my $p = $parts[$pt];
$text .= "\n" if ( $text );
my($type, $rnd) = $p->invisible_rendered(); if ( defined $rnd ) {
$text .= $rnd;
if ($html_needs_setting && $type eq 'text/html') {
$self->{metadata}->{html} = $p->{html_results};
}
}
}
$text =~ s/\n+\s*\n+/\f/gs; $text =~ tr/ \t\n\r\x0b\xa0/ /s; $text =~ tr/\f/\n/;
my @textary = split_into_array_of_short_lines ($text);
$self->{text_invisible_rendered} = \@textary;
return $self->{text_invisible_rendered};
}
sub get_decoded_body_text_array {
my ($self) = @_;
if (defined $self->{text_decoded}) { return $self->{text_decoded}; }
$self->{text_decoded} = [ ];
my @parts = $self->find_parts(qr/^(?:text|message)\b/i,1);
return $self->{text_decoded} unless @parts;
for(my $pt = 0 ; $pt <= $ next if ($parts[$pt]->{'type'} eq 'text/calendar');
push(@{$self->{text_decoded}}, "\n") if ( @{$self->{text_decoded}} );
push(@{$self->{text_decoded}}, $parts[$pt]->decode());
}
return $self->{text_decoded};
}
sub split_into_array_of_short_lines {
my @result = ();
foreach my $line (split (/^/m, $_[0])) {
while (length ($line) > MAX_BODY_LINE_LENGTH) {
my $length = rindex($line, ' ', MAX_BODY_LINE_LENGTH) + 1;
$length ||= MAX_BODY_LINE_LENGTH;
push (@result, substr($line, 0, $length, ''));
}
push (@result, $line);
}
@result;
}
1;
=back
=cut