source: trunk/gsdl/perllib/plugins/EMAILPlug.pm@ 2681

Last change on this file since 2681 was 2681, checked in by jrm21, 23 years ago

fixed a few more minor MIME header parsing cases.

  • Property svn:executable set to *
  • Property svn:keywords set to Author Date Id Revision
File size: 19.0 KB
Line 
1###########################################################################
2#
3# EMAILPlug.pm - a plugin for parsing email files
4#
5# A component of the Greenstone digital library software
6# from the New Zealand Digital Library Project at the
7# University of Waikato, New Zealand.
8#
9# Copyright (C) 1999 New Zealand Digital Library Project
10#
11# This program is free software; you can redistribute it and/or modify
12# it under the terms of the GNU General Public License as published by
13# the Free Software Foundation; either version 2 of the License, or
14# (at your option) any later version.
15#
16# This program is distributed in the hope that it will be useful,
17# but WITHOUT ANY WARRANTY; without even the implied warranty of
18# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19# GNU General Public License for more details.
20#
21# You should have received a copy of the GNU General Public License
22# along with this program; if not, write to the Free Software
23# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
24#
25###########################################################################
26
27
28
29# EMAILPlug
30#
31# by Gordon Paynter ([email protected])
32#
33# Email plug reads email files. These are named with a simple
34# number (i.e. as they appear in maildir folders) or with the
35# extension .mbx (for mbox mail file format)
36#
37# Document text:
38# The document text consists of all the text
39# after the first blank line in the document.
40#
41# Metadata:
42# $Headers All the header content
43# $Subject Subject: header
44# $To To: header
45# $From From: header - this will be stored as Creator
46# $FromName Name of sender (where available)
47# $FromAddr E-mail address of sender
48# $DateText Date: header
49# $Date Date: header in GSDL format (eg: 19990924)
50#
51#
52# John McPherson - June/July 2001
53# added (basic) MIME support and quoted-printable and base64 decodings.
54# Minor fixes for names that are actually email addresses (ie <...> was lost)
55#
56# See: * RFC 822 - ARPA Internet Text Messages
57# * RFC 2045 - Multipurpose Internet Mail Extensions (MIME) -part1
58# * RFC 2046 - MIME (part 2) Media Types (and multipart messages)
59# * RFC 1806 - Content Dispositions (ie inline/attachment)
60package EMAILPlug;
61
62use SplitPlug;
63
64use sorttools;
65use util;
66
67
68# EMAILPlug is a sub-class of SplitPlug.
69
70sub BEGIN {
71 @ISA = ('SplitPlug');
72}
73
74# Create a new EMAILPlug object with which to parse a file.
75# Accomplished by creating a new BasPlug and using bless to
76# turn it into an EMAILPlug.
77
78sub new {
79 my ($class) = @_;
80 my $self = new BasPlug ("EMAILPlug", @_);
81 # make sure we don't run textcat (defaults to "auto");
82 $self->{'input_encoding'}="ascii"; # this might not be good enough...
83 return bless $self, $class;
84}
85
86sub get_default_process_exp {
87 my $self = shift (@_);
88 # mbx/email for mailbox file format, \d+ for maildir (each message is
89 # in a separate file, with a unique number for filename)
90 return q@([\\/]\d+|\.(mbx|email))$@;
91}
92
93# This plugin splits the mbox mail files at lines starting with From<sp>
94sub get_default_split_exp {
95 return q^\nFrom .*\n^;
96}
97
98
99# do plugin specific processing of doc_obj
100sub process {
101
102 my $self = shift (@_);
103 my ($textref, $pluginfo, $base_dir, $file, $metadata, $doc_obj) = @_;
104 my $outhandle = $self->{'outhandle'};
105
106 # Check that we're dealing with a valid mail file
107 return undef unless (($$textref =~ /From:/m) || ($$textref =~ /To:/m));
108
109 # slightly more strict validity check, to prevent us from matching
110 # .so.x files ...
111 return undef unless (($$textref =~ /^From /) ||
112 ($$textref =~ /^[-A-Za-z]{2,100}:/m));
113
114 print $outhandle "EMAILPlug: processing $file\n"
115 if $self->{'verbosity'} > 1;
116
117 my $cursection = $doc_obj->get_top_section();
118
119 #
120 # Parse the document's text and extract metadata
121 #
122
123 # Protect backslashes
124 $$textref =~ s@\\@\\\\@g;
125
126 # Separate header from body of message
127 my $Headers = $$textref;
128 $Headers =~ s/\r?\n\r?\n(.*)$//s;
129 $$textref = $1;
130 # escape [] so it isn't re-interpreted as metadata
131 $Headers =~ s/\[/&#91;/g; $Headers =~ s/\]/&#93;/g;
132
133
134 # Unfold headers - see rfc822
135 $Headers =~ s/\r?\n[\t\ ]+/ /gs;
136 # Extract basic metadata from header
137 my @headers = ("From", "To", "Subject", "Date");
138 my %raw;
139 foreach my $name (@headers) {
140 $raw{$name} = "No $name value";
141 }
142
143 # Examine each line of the headers
144 my ($line, $name, $value);
145 my @parts;
146 foreach $line (split(/\n/, $Headers)) {
147
148 # Ignore lines with no content or which begin with whitespace
149 next unless ($line =~ /:/);
150 next if ($line =~ /^\s/);
151
152 # Find out what metadata is on this line
153 @parts = split(/:/, $line);
154 $name = shift @parts;
155# uppercase the first character according to the current locale
156 $name=~s/(.+)/\u$1/;
157 next unless $name;
158 next unless ($raw{$name});
159
160 # Find the value of that metadata
161 $value = join(":", @parts);
162 $value =~ s/^\s+//;
163 $value =~ s/\s+$//;
164
165 # Store the metadata
166 $raw{$name} = $value;
167 }
168
169 # Extract the name and e-mail address from the From metadata
170 $frommeta = $raw{"From"};
171 my $fromnamemeta;
172 my $fromaddrmeta;
173
174 $frommeta =~ s/\s*$//; # Remove trailing space, if any
175
176 if ($frommeta =~ m/(.+)\s*<(.+)>/) {
177 $fromnamemeta=$1;
178 $fromaddrmeta=$2;
179 } elsif ($frommeta =~ m/(.+@.+)\s+\((.*)\)/) {
180 $fromnamemeta=$2;
181 $fromaddrmeta=$1;
182 }
183 if (!defined($fromaddrmeta)) {
184 $fromaddrmeta=$frommeta;
185 }
186 $fromaddrmeta=~s/<//; $fromaddrmeta=~s/>//;
187 $doc_obj->add_utf8_metadata ($cursection, "FromAddr", $fromaddrmeta);
188
189 if (defined($fromnamemeta)) {
190 $fromnamemeta =~ s/\"//g;
191 }
192 else {
193 $fromnamemeta = $fromaddrmeta;
194 }
195 # if name is an address
196 $fromnamemeta =~ s/<//g; $fromnamemeta =~ s/>//g;
197 $doc_obj->add_utf8_metadata ($cursection, "FromName", $fromnamemeta);
198
199 # Escape < and > in the whole From field;
200 $frommeta =~ s/</&lt;/g; $frommeta =~ s/>/&gt;/g;
201 $raw{"From"}=$frommeta;
202
203 # Process Date information
204 if ($raw{"Date"} !~ /No Date/) {
205 $raw{"DateText"} = $raw{"Date"};
206
207 # Convert the date text to internal date format
208 $value = $raw{"Date"};
209 my ($day, $month, $year) = $value =~ /(\d?\d)\s([A-Z][a-z][a-z])\s(\d\d\d?\d?)/;
210 if ($year < 100) { $year += 1900; }
211 $raw{"Date"} = &sorttools::format_date($day, $month, $year);
212
213 } else {
214 # We have not extracted a date
215 $raw{"DateText"} = "Unknown.";
216 $raw{"Date"} = "19000000";
217 }
218
219
220 # Add extracted metadata to document object
221 foreach my $name (keys %raw) {
222 $value = $raw{$name};
223 if ($value) {
224 $value = &text_into_html($value);
225 } else {
226 $value = "No $name field";
227 }
228 $doc_obj->add_utf8_metadata ($cursection, $name, $value);
229 }
230
231 my $mimetype="text/plain";
232 my $mimeinfo="";
233 # Do MIME and encoding stuff
234 if ($Headers =~ /^content\-type:\s*([\w\/\-]+)\s*\;?\s*(.+?)\s*$/mi)
235 {
236 $mimetype=$1;
237 $mimetype =~ tr/[A-Z]/[a-z]/;
238 $mimeinfo=$2;
239 }
240
241 my $transfer_encoding="7bit";
242 if ($Headers =~ /^content-transfer-encoding:\s*([^\s]+)\s*$/mi) {
243 $transfer_encoding=$1;
244 }
245 if ($mimetype ne "text/plain") {
246 $$textref=text_from_mime_message($mimetype,$mimeinfo,$$textref,
247 $outhandle);
248 } elsif ($transfer_encoding =~ /quoted\-printable/) {
249 $$textref=qp_decode($$textref);
250 } elsif ($transfer_encoding =~ /base64/) {
251 $$textref=base64_decode($$textref);
252 }
253
254
255 # Add "All headers" metadata
256 $Headers = &text_into_html($Headers);
257 $Headers = "No headers" unless ($Headers =~ /\w/);
258 $doc_obj->add_utf8_metadata ($cursection, "Headers", $Headers);
259
260 # Add text to document object
261 if ($mimetype eq "text/plain") {
262 $$textref = &text_into_html($$textref);
263 }
264 $$textref = "No message" unless ($$textref =~ /\w/);
265 $doc_obj->add_utf8_text($cursection, $$textref);
266
267 return 1;
268}
269
270
271# Convert a text string into HTML.
272#
273# The HTML is going to be inserted into a GML file, so
274# we have to be careful not to use symbols like ">",
275# which ocurs frequently in email messages (and use
276# &gt instead.
277#
278# This function also turns links and email addresses into hyperlinks,
279# and replaces carriage returns with <BR> tags (and multiple carriage
280# returns with <P> tags).
281
282
283sub text_into_html {
284 my ($text) = @_;
285
286 # Convert problem characters into HTML symbols
287 $text =~ s/&/&amp;/go;
288 $text =~ s/</&lt;/go;
289 $text =~ s/>/&gt;/go;
290 $text =~ s/\"/&quot;/go;
291
292 # convert email addresses and URIs into links
293# don't markup email addresses for now
294# $text =~ s/([\w\d\.\-]+@[\w\d\.\-]+)/<a href=\"mailto:$1\">$1<\/a>/g;
295
296 # assume hostnames are \.\w\d\- only, then might have a trailing '/.*'
297 # URI can't finish with a '.'
298 $text =~ s/((http|ftp|https):\/\/[\w\d\-]+(\.[\w\d\-]+)*\/?((&amp;|\.)?[\w\d\?\=\-_\/~]+)*)/<a href=\"$1\">$1<\/a>/g;
299
300
301 # Clean up whitespace and convert \n charaters to <BR> or <P>
302 $text =~ s/ +/ /go;
303 $text =~ s/\s*$//o;
304 $text =~ s/^\s*//o;
305 $text =~ s/\n/\n<BR>/go;
306 $text =~ s/<BR>\s*<BR>/<P>/go;
307
308 return $text;
309}
310
311
312
313
314#Process a MIME message.
315# the textref we are given DOES NOT include the header.
316sub text_from_mime_message {
317 my ($mimetype,$mimeinfo,$text,$outhandle)=(@_);
318
319 # Check for multiparts - $mimeinfo will be a boundary
320 if ($mimetype =~ /multipart/) {
321 $boundary="";
322 if ($mimeinfo =~ /boundary=\"?([^"]+)\"?\s*$/im) {
323 $boundary=$1;
324 }
325 # parts start with "--$boundary"
326 # message ends with "--$boundary--"
327 # RFC says boundary is <70 chars, [A-Za-z'()+_,-./:=?], so escape any
328 # that perl might want to interpolate. Also allows spaces...
329 $boundary=~s/\\/\\\\/g;
330 $boundary=~s/([\?\+\.\(\)\:\/\'])/\\$1/g;
331 my @message_parts = split("\r?\n\-\-$boundary", "\n$text");
332 # remove first "part" and last "part" (final --)
333 shift @message_parts;
334 my $last=pop @message_parts;
335 # if our boundaries are a bit dodgy and we only found 1 part...
336 if (!defined($last)) {$last="";}
337 # make sure it is only -- and whitespace
338 if ($last !~ /^\-\-\s*$/ms) {
339 print $outhandle "EMAILPlug: (warning) last part of MIME message isn't empty\n";
340 }
341 foreach my $message_part (@message_parts) {
342 # remove the leading newline left from split.
343 $message_part=~s/^\r?\n//;
344 }
345 if ($mimetype eq "multipart/alternative") {
346 # check for an HTML version first, then TEXT, otherwise use first.
347 my $part_text="";
348 foreach my $message_part (@message_parts) {
349 if ($message_part =~ m@\s*content\-type:\s*text/html@mis)
350 {
351 # Use the HTML version
352 $part_text=text_from_part($message_part);
353 $mimetype="text/html";
354 last;
355 }
356 }
357 if ($part_text eq "") { # try getting a text part instead
358 foreach my $message_part (@message_parts) {
359 if ($message_part =~ m@^content\-type:\s*text/plain@mis)
360 {
361 # Use the plain version
362 $part_text=text_from_part($message_part);
363 $mimetype="text/plain";
364 last;
365 }
366 }
367 }
368 if ($part_text eq "") { # use first part
369 $part_text=text_from_part(shift @message_parts);
370 }
371 if ($part_text eq "") { # we couldn't get anything!!!
372 # or it was an empty message...
373 # do nothing...
374 print $outhandle "EMAILPlug: no text - empty body?\n";
375 } else {
376 $text=$part_text;
377 }
378 } elsif ($mimetype =~ m@multipart/(mixed|digest|related)@) {
379 $text="";
380 foreach my $message_part (@message_parts) {
381 my $part_header=$message_part;
382 my $part_body;
383 if ($message_part=~ /^\s*\n/) {
384 # no header... use defaults
385 $part_body=$message_part;
386 $part_header="Content-type: text/plain; charset=us-ascii";
387 } elsif ($part_header=~s/\r?\n\r?\n(.*)$//sg) {
388 $part_body=$1;
389 } else {
390 # something's gone wrong...
391 $part_header="";
392 $part_body=$message_part;
393 }
394
395 $part_header =~ s/\r?\n[\t\ ]+/ /gs; #unfold
396 my $part_content_type="";
397 my $part_content_info="";
398 if ($mimetype eq "multipart/digest") {
399 # default type - RTFRFC!!
400 $part_content_type="message/rfc822";
401 }
402 if ($part_header =~ m@^content\-type:\s*([\w+/\-]+)\s*\;?\s*([^\s]*)@mi) {
403 $part_content_type=$1; $part_content_type =~ tr/A-Z/a-z/;
404 $part_content_info=$2;
405 }
406 my $filename="";
407 if ($part_header =~ m@name=\"?([\w\.\-\\/]+)\"?@mis) {
408 $filename=$1;
409 }
410
411 # disposition - either inline or attachment.
412 # NOT CURRENTLY USED - we display all text types instead...
413 # $part_header =~ /^content\-disposition:\s*([\w+])/mis;
414
415 # add <<attachment>> to each part except the first...
416 if ($text ne "") {
417 $text.="<p><hr><strong>&lt;&lt;attachment&gt;&gt;</>";
418 # add part info header
419 $text.="<br>Type: $part_content_type<br>\n";
420 if ($filename ne "") {
421 $text.="Filename: $filename\n";
422 }
423 $text.="</strong></p>\n";
424 }
425
426 if ($part_content_type =~ m@text/@)
427 {
428 my $part_text=text_from_part($message_part);
429 if ($part_content_type !~ m@text/(ht|x)ml@) {
430 $part_text=text_into_html($part_text);
431 }
432 if ($part_text eq "") {
433 $part_text='&lt;&lt;empty message&gt;&gt;';
434 }
435 $text.=$part_text;
436 } elsif ($part_content_type =~ m@message/rfc822@) {
437 # This is a forwarded message
438 my $message_part_headers=$part_body;
439 $message_part_headers =~ s/\r?\n[\t\ ]+/ /gs; #unfold
440 $message_part_headers=~s/\r?\n\r?\n(.*)$//s;
441 my $message_part_body=$1;
442
443 my $rfc822_formatted_body=""; # put result in here
444 if ($message_part_headers =~
445 /^content\-type:\s*([\w\/\-]+)\s*\;?\s*?([^\s]+)?\s*$/ims)
446 {
447 # The message header uses MIME flags
448 my $message_content_type=$1;
449 my $message_content_info=$2;
450 if (!defined($message_content_info)) {
451 $message_content_info="";
452 }
453 $message_content_type =~ tr/A-Z/a-z/;
454 if ($message_content_type =~ /multipart/) {
455 $rfc822_formatted_body=
456 text_from_mime_message($message_content_type,
457 $message_content_info,
458 $message_part_body,
459 $outhandle);
460 } else {
461 $message_part_body=text_from_part($part_body);
462 $rfc822_formatted_body=text_into_html($message_part_body);
463 }
464 } else {
465 # message doesn't use MIME flags
466 $rfc822_formatted_body=text_into_html($message_part_body);
467 }
468 # Add the returned text to the output
469 # don't put all the headers...
470 $message_part_headers =~ s/^(X\-.*|received|message\-id|return\-path):.*\n//img;
471 $text.=text_into_html($message_part_headers);
472 $text.="<p>\n";
473 $text.=$rfc822_formatted_body;
474 # end of message/rfc822
475 } elsif ($part_content_type =~ /multipart/) {
476 # recurse again
477 $tmptext=text_from_mime_message($part_content_type,
478 $part_content_info,
479 $part_body,
480 $outhandle);
481 $text.=$tmptext;
482 } elsif ($text eq "") {
483 # we can't do anything with this part, but if it's the first
484 # part then make sure it is mentioned..
485
486 $text.="<p><hr><strong>&lt;&lt;attachment&gt;&gt;</>";
487 # add part info header
488 $text.="<br>Type: $part_content_type<br>\n";
489 if ($filename ne "") {
490 $text.="Filename: $filename\n";
491 }
492 $text.="</strong></p>\n";
493 }
494 } # foreach message part.
495 } else {
496 # we can't handle this multipart type (not mixed or alternative)
497 # the RFC also mentions "parallel".
498 }
499 } # end of multipart
500 return $text;
501}
502
503
504
505
506
507
508# Process a MIME part. Return "" if we can't decode it.
509sub text_from_part {
510 my $text=shift;
511 my $part_header=$text;
512 # check for empty part header (leading blank line)
513 if ($text =~ /^\s*\r?\n/) {
514 $part_header="Content-type: text/plain; charset=us-ascii";
515 } else {
516 $part_header =~ s/\r?\n\r?\n(.*)$//s;
517 $text=$1; if (!defined($text)) {$text="";}
518 }
519 $part_header =~ s/\r?\n[\t ]+/ /gs; #unfold
520 $part_header =~ /content\-type:\s*([\w\/]+)/is;
521 my $type=$1; if (!defined($type)) {$type="";}
522 my $encoding="";
523 if ($part_header =~ /^content\-transfer\-encoding:\s*([^\s]+)/mis) {
524 $encoding=$1; $encoding=~tr/A-Z/a-z/;
525 }
526 # Content-Transfer-Encoding is per-part
527 if ($encoding ne "") {
528 if ($encoding =~ /quoted\-printable/) {
529 $text=qp_decode($text);
530 } elsif ($encoding =~ /base64/) {
531 $text=base64_decode($text);
532 } elsif ($encoding !~ /[78]bit/) { # leave 7/8 bit as is.
533 # rfc2045 also allows binary, which we ignore (for now).
534 # maybe this shouldn't go to stderr, but anyway...
535 print STDERR "EMAILPlug: unknown encoding: $encoding\n";
536 return "";
537 }
538 }
539
540 if ($type eq "text/html") {
541 # only get stuff between <body> tags, or <html> tags.
542 $text =~ s/^.*?<(html|HTML)[^>]*>//s;
543 $text =~ s/<\/(html|HTML)>.*$//s;
544
545 $text =~ s/^.*?<(body|BODY)[^>]*>//s;
546 $text =~ s/<\/(body|BODY)>.*$//s;
547 }
548 elsif ($type eq "text/xml") {
549 $text=~s/</&lt;/g;$text=~s/>/&gt;/g;
550 $text="<pre>\n$text\n</pre>\n";
551 }
552 return $text;
553}
554
555
556# decode quoted-printable text
557sub qp_decode {
558 my $text=shift;
559
560 my @lines=split('\n', $text);
561
562 # if a line ends with "=\s*", it is a soft line break, otherwise
563 # keep in any newline characters.
564 foreach my $line (@lines) {
565 if ($line =~ s/=\s*$//) {}
566 else {$line.="\n";}
567
568 if ($line =~ /=[0-9A-Fa-f]{2}/) { # it contains an escaped char
569 my @hexcode_segments=split('=',$line);
570 shift @hexcode_segments;
571 my @hexcodes;
572 foreach my $hexcode (@hexcode_segments) {
573 $hexcode =~ s/^(..).*$/$1/; # only need first 2 chars
574 chomp($hexcode); # just in case...
575 my $char=chr (hex "0x$hexcode");
576 $line =~ s/=$hexcode/$char/g;
577 }
578 }
579 }
580 $text= join('', @lines);
581 return $text;
582}
583
584# decode base64 text. This is fairly slow (since it's interpreted perl rather
585# than compiled XS stuff like in the ::MIME modules, but this is more portable
586# for us at least).
587# see rfc2045 for description, but basically, bits 7 and 8 are set to zero;
588# 4 bytes of encoded text become 3 bytes of binary - remove 2 highest bits
589# from each encoded byte.
590
591
592sub base64_decode {
593 my $enc_text = shift;
594# A=>0, B=>1, ..., '+'=>62, '/'=>63
595# also '=' is used for padding at the end, but we remove it anyway.
596 my $mimechars="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
597# map each MIME char into it's value, for more efficient lookup.
598 my %index;
599 map { $index{$_} = index ($mimechars, $_) } (split ('', $mimechars));
600# remove all non-base64 chars. eval to get variable in transliteration...
601# also remove '=' - we'll assume (!!) that there are no errors in the encoding
602 eval "\$enc_text =~ tr|$mimechars||cd";
603 my $decoded="";
604 while (length ($enc_text)>3)
605 {
606 my $fourchars=substr($enc_text,0,4,"");
607 my @chars=(split '',$fourchars);
608 $decoded.=chr( $index{$chars[0]} << 2 | $index{$chars[1]} >> 4);
609 $decoded.=chr( ($index{$chars[1]} & 15) << 4 | $index{$chars[2]} >> 2);
610 $decoded.=chr( ($index{$chars[2]} & 3 ) << 6 | $index{$chars[3]});
611 }
612# if there are any input chars left, there are either
613# 2 encoded bytes (-> 1 raw byte) left or 3 encoded (-> 2 raw) bytes left.
614 my @chars=(split '',$enc_text);
615 if (length($enc_text)) {
616 $decoded.=chr($index{$chars[0]} << 2 | (int $index{$chars[1]} >> 4));
617 }
618 if (length($enc_text)==3) {
619 $decoded.=chr( ($index{$chars[1]} & 15) << 4 | $index{$chars[2]} >> 2);
620 }
621 return $decoded;
622}
623
624
625# Perl packages have to return true if they are run.
6261;
Note: See TracBrowser for help on using the repository browser.