package Sisimai::Data; use feature ':5.10'; use strict; use warnings; use Class::Accessor::Lite; use Sisimai::Address; use Sisimai::String; use Sisimai::Reason; use Sisimai::Rhost; use Sisimai::Time; use Sisimai::DateTime; my $rwaccessors = [ 'catch', # [?] Results generated by hook method 'token', # [String] Message token/MD5 Hex digest value 'lhost', # [String] local host name/Local MTA 'rhost', # [String] Remote host name/Remote MTA 'alias', # [String] Alias of the recipient address 'listid', # [String] List-Id header of each ML 'reason', # [String] Bounce reason 'action', # [String] The value of Action: header 'subject', # [String] UTF-8 Subject text 'timestamp', # [Sisimai::Time] Date: header in the original message 'addresser', # [Sisimai::Address] From address 'recipient', # [Sisimai::Address] Recipient address which bounced 'messageid', # [String] Message-Id: header 'replycode', # [String] SMTP Reply Code 'smtpagent', # [String] Module(Engine) name 'softbounce', # [Integer] 1 = Soft bounce, 0 = Hard bounce, -1 = ? 'smtpcommand', # [String] The last SMTP command 'destination', # [String] The domain part of the "recipinet" 'senderdomain', # [String] The domain part of the "addresser" 'feedbacktype', # [String] Feedback Type 'diagnosticcode', # [String] Diagnostic-Code: Header 'diagnostictype', # [String] The 1st part of Diagnostic-Code: Header 'deliverystatus', # [String] Delivery Status(DSN) 'timezoneoffset', # [Integer] Time zone offset(seconds) ]; Class::Accessor::Lite->mk_accessors(@$rwaccessors); my $EndOfEmail = Sisimai::String->EOM; my $RetryIndex = Sisimai::Reason->retry; my $RFC822Head = Sisimai::RFC5322->HEADERFIELDS('all'); my $AddrHeader = { 'addresser' => $RFC822Head->{'addresser'}, 'recipient' => $RFC822Head->{'recipient'}, }; my $ActionHead = { 'failure' => 'failed', 'expired' => 'delayed' }; sub new { # Constructor of Sisimai::Data # @param [Hash] argvs Data # @return [Sisimai::Data] Structured email data my $class = shift; my $argvs = { @_ }; my $thing = {}; # Create email address object my $as = Sisimai::Address->make($argvs->{'addresser'}); my $ar = Sisimai::Address->make({ 'address' => $argvs->{'recipient'} }); return undef unless ref $as eq 'Sisimai::Address'; return undef unless ref $ar eq 'Sisimai::Address'; $thing = { 'addresser' => $as, 'recipient' => $ar, 'senderdomain' => $as->host, 'destination' => $ar->host, 'alias' => $argvs->{'alias'} || $ar->alias, 'token' => Sisimai::String->token($as, $ar, $argvs->{'timestamp'}), }; # Create Sisimai::Time object $thing->{'timestamp'} = gmtime Sisimai::Time->new($argvs->{'timestamp'}); $thing->{'timezoneoffset'} = $argvs->{'timezoneoffset'} // '+0000'; # Callback method $thing->{'catch'} = $argvs->{'catch'} // undef; my @v1 = (qw| listid subject messageid smtpagent diagnosticcode diagnostictype deliverystatus reason lhost rhost smtpcommand feedbacktype action softbounce replycode |); $thing->{ $_ } = $argvs->{ $_ } // '' for @v1; $thing->{'replycode'} ||= Sisimai::SMTP::Reply->find($argvs->{'diagnosticcode'}); return bless($thing, __PACKAGE__); } sub make { # Another constructor of Sisimai::Data # @param [Hash] argvs Data and orders # @option argvs [Sisimai::Message] Data Object # @return [Array, Undef] List of Sisimai::Data or Undef if the # argument is not Sisimai::Message object my $class = shift; my $argvs = { @_ }; return undef unless exists $argvs->{'data'}; return undef unless ref $argvs->{'data'} eq 'Sisimai::Message'; return undef unless $argvs->{'data'}->ds; return undef unless $argvs->{'data'}->rfc822; my $delivered1 = $argvs->{'delivered'} // 0; my $messageobj = $argvs->{'data'}; my $rfc822data = $messageobj->rfc822; my $fieldorder = { 'recipient' => [], 'addresser' => [] }; my $objectlist = []; my $givenorder = $argvs->{'order'} ? $argvs->{'order'} : {}; # Decide the order of email headers: user specified or system default. if( ref $givenorder eq 'HASH' && scalar keys %$givenorder ) { # If the order of headers for searching is specified, use the order # for detecting an email address. for my $e ( keys %$fieldorder ) { # The order should be "Array Reference". next unless $givenorder->{ $e }; next unless ref $givenorder->{ $e } eq 'ARRAY'; next unless scalar @{ $givenorder->{ $e } }; push @{ $fieldorder->{ $e } }, @{ $givenorder->{ $e } }; } } for my $e ( keys %$fieldorder ) { # If the order is empty, use default order. next if scalar @{ $fieldorder->{ $e } }; # Load default order of each accessor. $fieldorder->{ $e } = $AddrHeader->{ $e }; } LOOP_DELIVERY_STATUS: for my $e ( @{ $messageobj->ds } ) { # Create parameters for new() constructor. my $o = undef; # Sisimai::Data Object my $r = undef; # Reason text my $p = { 'catch' => $messageobj->catch // undef, 'lhost' => $e->{'lhost'} // '', 'rhost' => $e->{'rhost'} // '', 'alias' => $e->{'alias'} // '', 'action' => $e->{'action'} // '', 'reason' => $e->{'reason'} // '', 'replycode' => $e->{'replycode'} // '', 'smtpagent' => $e->{'agent'} // '', 'recipient' => $e->{'recipient'} // '', 'softbounce' => $e->{'softbounce'} // '', 'smtpcommand' => $e->{'command'} // '', 'feedbacktype' => $e->{'feedbacktype'} // '', 'diagnosticcode' => $e->{'diagnosis'} // '', 'diagnostictype' => $e->{'spec'} // '', 'deliverystatus' => $e->{'status'} // '', }; unless( $delivered1 ) { # Skip if the value of "deliverystatus" begins with "2." such as 2.1.5 next if index($p->{'deliverystatus'}, '2.') == 0; } EMAIL_ADDRESS: { # Detect email address from message/rfc822 part my $h = undef; my $j = undef; for my $f ( @{ $fieldorder->{'addresser'} } ) { # Check each header in message/rfc822 part $h = lc $f; next unless exists $rfc822data->{ $h }; next unless $rfc822data->{ $h }; $j = Sisimai::Address->find($rfc822data->{ $h }) || []; next unless scalar @$j; $p->{'addresser'} = $j->[0]; last; } unless( $p->{'addresser'} ) { # Fallback: Get the sender address from the header of the bounced # email if the address is not set at loop above. $j = Sisimai::Address->find($messageobj->{'header'}->{'to'}) || []; $p->{'addresser'} = $j->[0] if scalar @$j; } } next unless $p->{'addresser'}; next unless $p->{'recipient'}; TIMESTAMP: { # Convert from a time stamp or a date string to a machine time. my $datestring = undef; my $zoneoffset = 0; my @datevalues = (); push @datevalues, $e->{'date'} if $e->{'date'}; # Date information did not exist in message/delivery-status part,... for my $f ( @{ $RFC822Head->{'date'} } ) { # Get the value of Date header or other date related header. next unless $rfc822data->{ lc $f }; push @datevalues, $rfc822data->{ lc $f }; } # Set "date" getting from the value of "Date" in the bounce message push @datevalues, $messageobj->{'header'}->{'date'} if scalar(@datevalues) < 2; while( my $v = shift @datevalues ) { # Parse each date value in the array $datestring = Sisimai::DateTime->parse($v); last if $datestring; } if( defined $datestring ) { # Get the value of timezone offset from $datestring if( $datestring =~ /\A(.+)[ ]+([-+]\d{4})\z/ ) { # Wed, 26 Feb 2014 06:05:48 -0500 $datestring = $1; $zoneoffset = Sisimai::DateTime->tz2second($2); $p->{'timezoneoffset'} = $2; } } eval { # Convert from the date string to an object then calculate time # zone offset. my $t = Sisimai::Time->strptime($datestring, '%a, %d %b %Y %T'); $p->{'timestamp'} = ($t->epoch - $zoneoffset) // undef; }; } next unless $p->{'timestamp'}; OTHER_TEXT_HEADERS: { # Scan "Received:" header of the original message my $recvheader = $argvs->{'data'}->{'header'}->{'received'} || []; if( scalar @$recvheader ) { # Get localhost and remote host name from Received header. $e->{'lhost'} ||= shift @{ Sisimai::RFC5322->received($recvheader->[0]) }; $e->{'rhost'} ||= pop @{ Sisimai::RFC5322->received($recvheader->[-1]) }; } for my $v ('rhost', 'lhost') { $p->{ $v } =~ y/[]()//d; # Remove square brackets and curly brackets from the host variable $p->{ $v } =~ s/\A.+=//; # Remove string before "=" $p->{ $v } =~ s/\r\z//g; # Remove CR at the end of the value # Check space character in each value and get the first element $p->{ $v } = (split(' ', $p->{ $v }, 2))[0] if rindex($p->{ $v }, ' ') > -1; $p->{ $v } =~ s/[.]\z//; # Remove "." at the end of the value } # Subject: header of the original message $p->{'subject'} = $rfc822data->{'subject'} // ''; $p->{'subject'} =~ s/\r\z//g; # The value of "List-Id" header $p->{'listid'} = $rfc822data->{'list-id'} // ''; if( $p->{'listid'} ) { # Get the value of List-Id header: "List name " $p->{'listid'} = $1 if $p->{'listid'} =~ /\A.*([<].+[>]).*\z/; $p->{'listid'} =~ y/<>//d; $p->{'listid'} =~ s/\r\z//g; $p->{'listid'} = '' if rindex($p->{'listid'}, ' ') > -1; } # The value of "Message-Id" header $p->{'messageid'} = $rfc822data->{'message-id'} // ''; if( $p->{'messageid'} ) { # Leave only string inside of angle brackets(<>) $p->{'messageid'} = $1 if $p->{'messageid'} =~ /\A([^ ]+)[ ].*/; $p->{'messageid'} = $1 if $p->{'messageid'} =~ /[<]([^ ]+?)[>]/; } CHECK_DELIVERY_STATUS_VALUE: { # Cleanup the value of "Diagnostic-Code:" header $p->{'diagnosticcode'} =~ s/[ \t.]+$EndOfEmail//; $p->{'diagnosticcode'} =~ s/\r\z//g; if( $p->{'diagnosticcode'} ) { # Count the number of D.S.N. and SMTP Reply Code my $vs = Sisimai::SMTP::Status->find($p->{'diagnosticcode'}); my $vr = Sisimai::SMTP::Reply->find($p->{'diagnosticcode'}); my $vm = 0; if( $vs ) { # How many times does the D.S.N. appeared $vm += 1 while $p->{'diagnosticcode'} =~ /\b\Q$vs\E\b/g; $p->{'deliverystatus'} = $vs if $vs =~ /\A[45][.][1-9][.][1-9]\z/; } if( $vr ) { # How many times does the SMTP reply code appeared $vm += 1 while $p->{'diagnosticcode'} =~ /\b$vr\b/g; $p->{'replycode'} ||= $vr; } if( $vm > 2 ) { # Build regular expression for removing string like '550-5.1.1' # from the value of "diagnosticcode" my $re = qr/[ ]$vr[- ](?:\Q$vs\E)?/; # 550-5.7.1 [192.0.2.222] Our system has detected that this message is # 550-5.7.1 likely unsolicited mail. To reduce the amount of spam sent to Gmail, # 550-5.7.1 this message has been blocked. Please visit # 550 5.7.1 https://support.google.com/mail/answer/188131 for more information. $p->{'diagnosticcode'} =~ s/$re/ /g; $p->{'diagnosticcode'} = Sisimai::String->sweep($p->{'diagnosticcode'}); } } $p->{'diagnostictype'} ||= 'X-UNIX' if $p->{'reason'} eq 'mailererror'; $p->{'diagnostictype'} ||= 'SMTP' unless $p->{'reason'} =~ /\A(?:feedback|vacation)\z/; } # Check the value of SMTP command $p->{'smtpcommand'} = '' unless $p->{'smtpcommand'} =~ /\A(?:EHLO|HELO|MAIL|RCPT|DATA|QUIT)\z/; if( $p->{'action'} ) { # Action: expanded (to multi-recipient alias) $p->{'action'} = $1 if $p->{'action'} =~ /\A(.+?) .+/; unless( $p->{'action'} =~ /\A(?:failed|delayed|delivered|relayed|expanded)\z/ ) { # The value of "action" is not in the following values: # "failed" / "delayed" / "delivered" / "relayed" / "expanded" for my $q ( keys %$ActionHead ) { next unless $p->{'action'} eq $q; $p->{'action'} = $ActionHead->{ $q }; last; } } } else { if( $p->{'reason'} eq 'expired' ) { # Action: delayed $p->{'action'} = 'delayed'; } elsif( index($p->{'deliverystatus'}, '5') == 0 || index($p->{'deliverystatus'}, '4') == 0 ) { # Action: failed $p->{'action'} = 'failed'; } } } $o = __PACKAGE__->new(%$p); next unless defined $o; if( $o->reason eq '' || grep { $o->reason eq $_ } @$RetryIndex ) { # Decide the reason of email bounce $r = Sisimai::Rhost->get($o) if Sisimai::Rhost->match($o->rhost); # Remote host dependent error $r ||= Sisimai::Reason->get($o); $r ||= 'undefined'; $o->reason($r); } if( $o->reason eq 'delivered' || $o->reason eq 'feedback' || $o->reason eq 'vacation' ) { # The value of reason is "delivered", "vacation" or "feedback". $o->softbounce(-1); $o->replycode('') unless $o->reason eq 'delivered'; } else { # Bounce message which reason is "feedback" or "vacation" does # not have the value of "deliverystatus". my $softorhard = undef; my $textasargv = undef; unless( length $o->softbounce ) { # Set the value of softbounce $textasargv = $p->{'deliverystatus'}.' '.$p->{'diagnosticcode'}; $textasargv =~ s/\A[ ]//g; $softorhard = Sisimai::SMTP::Error->soft_or_hard($o->reason, $textasargv); if( $softorhard ) { # Returned value is "soft" or "hard" $o->softbounce($softorhard eq 'soft' ? 1 : 0); } else { # Returned value is an empty string or undef $o->softbounce(-1); } } unless( $o->deliverystatus ) { # Set pseudo status code my $pseudocode = undef; # Pseudo delivery status code my $getchecked = undef; # Permanent error or not my $tmpfailure = undef; # Temporary error $textasargv = $o->replycode.' '.$p->{'diagnosticcode'}; $textasargv =~ s/\A[ ]//g; $getchecked = Sisimai::SMTP::Error->is_permanent($textasargv); $tmpfailure = defined $getchecked ? ( $getchecked == 1 ? 0 : 1 ) : 0; $pseudocode = Sisimai::SMTP::Status->code($o->reason, $tmpfailure); if( $pseudocode ) { # Set the value of "deliverystatus" and "softbounce". $o->deliverystatus($pseudocode); if( $o->softbounce == -1 ) { # Set the value of "softbounce" again when the value is -1 $softorhard = Sisimai::SMTP::Error->soft_or_hard($o->reason, $pseudocode); if( $softorhard ) { # Returned value is "soft" or "hard" $o->softbounce($softorhard eq 'soft' ? 1 : 0); } else { # Returned value is an empty string or undef $o->softbounce(-1); } } } } if( $o->replycode ) { # Check both of the first digit of "deliverystatus" and "replycode" my $d1 = substr($o->deliverystatus, 0, 1); my $r1 = substr($o->replycode, 0, 1); $o->replycode('') unless $d1 eq $r1; } } push @$objectlist, $o; } # End of for(LOOP_DELIVERY_STATUS) return $objectlist; } sub damn { # Convert from object to hash reference # @return [Hash] Data in Hash reference my $self = shift; my $data = undef; eval { my $v = {}; my @stringdata = (qw| token lhost rhost listid alias reason subject messageid smtpagent smtpcommand destination diagnosticcode senderdomain deliverystatus timezoneoffset feedbacktype diagnostictype action replycode catch softbounce |); for my $e ( @stringdata ) { # Copy string data $v->{ $e } = $self->$e // ''; } $v->{'addresser'} = $self->addresser->address; $v->{'recipient'} = $self->recipient->address; $v->{'timestamp'} = $self->timestamp->epoch; $data = $v; }; return $data; } sub dump { # Data dumper # @param [String] type Data format: json, yaml # @return [String, Undef] Dumped data or Undef if the value of first # argument is neither "json" nor "yaml" my $self = shift; my $type = shift || 'json'; return undef unless $type =~ /\A(?:json|yaml)\z/; my $dumpeddata = ''; my $referclass = 'Sisimai::Data::'.uc($type); my $modulepath = 'Sisimai/Data/'.uc($type).'.pm'; require $modulepath; $dumpeddata = $referclass->dump($self); return $dumpeddata; } 1; __END__ =encoding utf-8 =head1 NAME Sisimai::Data - Parsed data object =head1 SYNOPSIS use Sisimai::Data; my $data = Sisimai::Data->make('data' => object); for my $e ( @$data ) { print $e->reason; # userunknown, mailboxfull, and so on. print $e->recipient->address; # (Sisimai::Address) envelope recipient address print $e->bonced->ymd # (Sisimai::Time) Date of bounce } =head1 DESCRIPTION Sisimai::Data generate parsed data from Sisimai::Message object. =head1 CLASS METHODS =head2 C)>> C generate parsed data and returns an array reference which are including Sisimai::Data objects. my $mail = Sisimai::Mail->new('/var/mail/root'); while( my $r = $mail->read ) { my $mesg = Sisimai::Message->new('data' => $r); my $data = Sisimai::Data->make('data' => $mesg); for my $e ( @$data ) { print $e->reason; # userunknown, mailboxfull, and so on. print $e->recipient->address; # (Sisimai::Address) envelope recipient address print $e->timestamp->ymd # (Sisimai::Time) Date of the email bounce } } If you want to get bounce records which reason is "delivered", set "delivered" option to make() method like the following: my $data = Sisimai::Data->make('data' => $mesg, 'delivered' => 1); Beginning from v4.19.0, `hook` argument is available to callback user defined method like the following codes: my $call = sub { my $argv = shift; my $fish = { 'x-mailer' => '' }; if( $argv->{'message'} =~ /^X-Mailer:\s*(.+)$/m ) { $fish->{'x-mailer'} = $1; } return $fish; }; my $mesg = Sisimai::Message->new('data' => $mailtxt, 'hook' => $call); my $data = Sisimai::Data->make('data' => $mesg); for my $e ( @$data ) { print $e->catch->{'x-mailer'}; # Apple Mail (2.1283) } =head1 INSTANCE METHODS =head2 C> C convert the object to a hash reference. my $hash = $self->damn; print $hash->{'recipient'}; # user@example.jp print $hash->{'timestamp'}; # 1393940000 =head1 PROPERTIES Sisimai::Data have the following properties: =head2 C (I) C is the value of Action: field in a bounce email message such as C or C. Action: failed =head2 C (I C is L object generated from the sender address. When Sisimai::Data object is dumped as JSON, this value converted to an email address. Sisimai::Address object have the following accessors: =over =item - user() - the local part of the address =item - host() - the domain part of the address =item - address() - email address =item - verp() - variable envelope return path =item - alias() - alias of the address =back From: "Kijitora Cat" =head2 C (I) C is an alias address of the recipient. When the Original-Recipient: field or C string did not exist in a bounce message, this value is empty. Original-Recipient: rfc822;kijitora@example.org "|IFS=' ' && exec /usr/local/bin/procmail -f- || exit 75 #kijitora" (expanded from: ) =head2 C (I) C is the value of Status: field in a bounce message. When the message has no Status: field, Sisimai set pseudo value like 5.0.9XX to this value. The range of values only C<4.x.x> or C<5.x.x>. Status: 5.0.0 (permanent failure) =head2 C (I) C is the domain part of the recipient address. This value is the same as the return value from host() method of C accessor. =head2 C (I) C is an error message picked from Diagnostic-Code: field or message body in a bounce message. This value and the value of C, C, C, C, and C will be referred by L to decide the bounce reason. Diagnostic-Code: SMTP; 554 5.4.6 Too many hops =head2 C (C) C is a type like C or C picked from Diagnostic-Code: field in a bounce message. When there is no Diagnostic-Code: field in the bounce message, this value will be empty. Diagnostic-Code: X-Unix; 255 =head2 C (I) C is the value of Feedback-Type: field like C, C, C in a bounce message. When the message is not ARF format or the value of C is not C, this value will be empty. Content-Type: message/feedback-report Feedback-Type: abuse User-Agent: SMP-FBL =head2 C (I) C is a local MTA name to be used as a gateway for sending email message or the value of Reporting-MTA field in a bounce message. When there is no Reporting-MTA field in the bounce message, Sisimai try to get the value from Received header. Reporting-MTA: dns; mx4.smtp.example.co.jp =head2 C (I) C is the value of List-Id header of the original message. When there is no List-Id field in the original message or the bounce message did not include the original message, this value will be empty. List-Id: Mailman mailing list management users =head2 C (I) C is the value of Message-Id header of the original message. When the original message did not include Message-Id: header or the bounce message did not include the original message, this value will be empty. Message-Id: <201310160515.r9G5FZh9018575@smtpgw.example.jp> =head2 C (I C is L object generated from the recipient address. When Sisimai::Data object is dumped as JSON, this value converted to an email address. Sisimai::Address object have the following accessors: =over =item - user() - the local part of the address =item - host() - the domain part of the address =item - address() - email address =item - verp() - variable envelope return path =item - alias() - alias of the address =back Final-Recipient: RFC822; shironeko@example.ne.jp X-Failed-Recipients: kijitora@example.ed.jp =head2 C (I) C is the value of bounce reason Sisimai detected. When this value is C or C, it means that Sisimai could not decide the reason. All the reasons Sisismai can detect are available at L or web site L. =head2 C (I) C is the value of SMTP reply code picked from the error message or the value of Diagnostic-Code: field in a bounce message. The range of values is only 4xx or 5xx. ----- The following addresses had permanent fatal errors ----- (reason: 550 5.1.1 ... User Unknown) =head2 C (I) C is a remote MTA name which has rejected the message you sent or the value of Remote-MTA: field in a bounce message. When there is no Remote-MTA field in the bounce message, Sisimai try to get the value from Received header. Remote-MTA: DNS; g5.example.net =head2 C (I) C is the domain part of the sender address. This value is the same as the return value from host() method of addresser accessor. =head2 C (I) C is a module name to be used for detecting bounce reason. For example, when the value is C, Sisimai used L to get the recipient address and other delivery status information from a bounce message. =head2 C (I) C is a SMTP command name picked from the error message or the value of Diagnostic-Code: field in a bounce message. When there is no SMTP command in the bounce message, this value will be empty. The list of values is C, C, C, C, and C. : host mx1.example.go.jp[192.0.2.127] said: 550 5.1.6 recipient no longer on server: kijitora@example.go.jp (in reply to RCPT TO command) =head2 C (I) The value of C indicates whether the reason of the bounce is soft bounce or hard bounce. This accessor has added in Sisimai 4.1.28. The range of the values are the followings: =over =item 1 = Soft bounce =item 0 = Hard bounce =item -1 = Sisimai could not decide =back =head2 C (I) C is the value of Subject header of the original message. When the original message which is included in a bounce email contains no Subject header (removed by remote MTA), this value will be empty. If the value of Subject header of the original message contain any multibyte character (non-ASCII character), such as MIME encoded Japanese or German and so on, the value of subject in parsed data is encoded with UTF-8 again. =head2 C (I) C is an identifier of each email-bounce. The token string is created from the sender email address (addresser) and the recipient email address (recipient) and the machine time of the date in a bounce message as an MD5 hash value. The token value is generated at C method of L class. If you want to get the same token string at command line, try to run the following command: % printf "\x02%s\x1e%s\x1e%d\x03" sender@example.jp recipient@example.org `date '+%s'` | md5 714d72dfd972242ad04f8053267e7365 =head2 C (I) C is the date which email has bounced as a L (Child class of Time::Piece) object. When Sisimai::Data object is dumped as JSON, this value will be converted to an UNIX machine time (32 bits integer). Arrival-Date: Thu, 29 Apr 2009 23:45:33 +0900 =head2 C (I) C is a time zone offset of a bounce email which its email has bounced. The format of this value is String like C<+0900>, C<-0200>. If Sisimai has failed to get a value of time zone offset, this value will be set as C<+0000>. =head1 SEE ALSO L =head1 AUTHOR azumakuniyuki =head1 COPYRIGHT Copyright (C) 2014-2018 azumakuniyuki, All rights reserved. =head1 LICENSE This software is distributed under The BSD 2-Clause License. =cut