4 use vars qw ( @ISA @EXPORT_OK $DEBUG );
8 use IPC::Run qw( run timeout ); # for _pslatex
9 use IPC::Run3; # for do_print... should just use IPC::Run i guess
13 #do NOT depend on any FS:: modules here, causes weird (sometimes unreproducable
14 #until on client machine) dependancy loops. put them in FS::Misc::Something
17 @ISA = qw( Exporter );
18 @EXPORT_OK = qw( send_email generate_email send_fax
19 states_hash counties cities state_label
22 generate_ps generate_pdf do_print
31 FS::Misc - Miscellaneous subroutines
35 use FS::Misc qw(send_email);
41 Miscellaneous subroutines. This module contains miscellaneous subroutines
42 called from multiple other modules. These are not OO or necessarily related,
43 but are collected here to eliminate code duplication.
49 =item send_email OPTION => VALUE ...
61 (required) comma-separated scalar or arrayref of recipients
69 (optional) MIME type for the body
73 (required unless I<nobody> is true) arrayref of body text lines
77 (optional, but required if I<nobody> is true) arrayref of MIME::Entity->build PARAMHASH refs or MIME::Entity objects. These will be passed as arguments to MIME::Entity->attach().
81 (optional) when set true, send_email will ignore the I<body> option and simply construct a message with the given I<mimeparts>. In this case,
82 I<content-type>, if specified, overrides the default "multipart/mixed" for the outermost MIME container.
84 =item content-encoding
86 (optional) when using nobody, optional top-level MIME
87 encoding which, if specified, overrides the default "7bit".
91 (optional) type parameter for multipart/related messages
95 (optional) L<FS::cust_main> key; if passed, the message will be logged
96 (if logging is enabled) with this custnum.
100 (optional) L<FS::msg_template> key, for logging.
106 use vars qw( $conf );
109 use Email::Sender::Simple qw(sendmail);
110 use Email::Sender::Transport::SMTP;
111 use Email::Sender::Transport::SMTP::TLS 0.11;
114 FS::UID->install_callback( sub {
115 $conf = new FS::Conf;
121 my %doptions = %options;
122 $doptions{'body'} = '(full body not shown in debug)';
123 warn "FS::Misc::send_email called with options:\n ". Dumper(\%doptions);
124 # join("\n", map { " $_: ". $options{$_} } keys %options ). "\n"
127 my @to = ref($options{to}) ? @{ $options{to} } : ( $options{to} );
131 if ( $options{'nobody'} ) {
133 croak "'mimeparts' option required when 'nobody' option given\n"
134 unless $options{'mimeparts'};
136 @mimeparts = @{$options{'mimeparts'}};
139 'Type' => ( $options{'content-type'} || 'multipart/mixed' ),
140 'Encoding' => ( $options{'content-encoding'} || '7bit' ),
145 @mimeparts = @{$options{'mimeparts'}}
146 if ref($options{'mimeparts'}) eq 'ARRAY';
148 if (scalar(@mimeparts)) {
151 'Type' => 'multipart/mixed',
152 'Encoding' => '7bit',
155 unshift @mimeparts, {
156 'Type' => ( $options{'content-type'} || 'text/plain' ),
157 'Data' => $options{'body'},
158 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
159 'Disposition' => 'inline',
165 'Type' => ( $options{'content-type'} || 'text/plain' ),
166 'Data' => $options{'body'},
167 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
175 if ( $options{'from'} =~ /\@([\w\.\-]+)/ ) {
178 warn 'no domain found in invoice from address '. $options{'from'}.
179 '; constructing Message-ID (and saying HELO) @example.com';
180 $domain = 'example.com';
182 my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
185 my $message = MIME::Entity->build(
186 'From' => $options{'from'},
187 'To' => join(', ', @to),
188 'Sender' => $options{'from'},
189 'Reply-To' => $options{'from'},
190 'Date' => time2str("%a, %d %b %Y %X %z", $time),
191 'Subject' => Encode::encode('MIME-Header', $options{'subject'}),
192 'Message-ID' => "<$message_id>",
196 if ( $options{'type'} ) {
197 #false laziness w/cust_bill::generate_email
198 $message->head->replace('Content-type',
200 '; boundary="'. $message->head->multipart_boundary. '"'.
201 '; type='. $options{'type'}
205 foreach my $part (@mimeparts) {
207 if ( UNIVERSAL::isa($part, 'MIME::Entity') ) {
209 warn "attaching MIME part from MIME::Entity object\n"
211 $message->add_part($part);
213 } elsif ( ref($part) eq 'HASH' ) {
215 warn "attaching MIME part from hashref:\n".
216 join("\n", map " $_: ".$part->{$_}, keys %$part ). "\n"
218 $message->attach(%$part);
221 croak "mimepart $part isn't a hashref or MIME::Entity object!";
228 my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
232 my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
233 $smtp_opt{'port'} = $port;
236 if ( defined($enc) && $enc eq 'starttls' ) {
237 $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
238 $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
240 if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
241 $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
243 $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
244 $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
247 push @to, $options{bcc} if defined($options{bcc});
248 local $@; # just in case
249 eval { sendmail($message, { transport => $transport,
250 from => $options{from},
254 if(ref($@) and $@->isa('Email::Sender::Failure')) {
255 $error = $@->code.' ' if $@->code;
256 $error .= $@->message;
263 if ( $conf->exists('log_sent_mail') and $options{'custnum'} ) {
264 my $cust_msg = FS::cust_msg->new({
265 'env_from' => $options{'from'},
266 'env_to' => join(', ', @to),
267 'header' => $message->header_as_string,
268 'body' => $message->body_as_string,
271 'custnum' => $options{'custnum'},
272 'msgnum' => $options{'msgnum'},
273 'status' => ($error ? 'failed' : 'sent'),
275 $cust_msg->insert; # ignore errors
281 =item generate_email OPTION => VALUE ...
289 Sender address, required
293 Recipient address, required
297 Blind copy address, optional
301 email subject, required
305 Email body (HTML alternative). Arrayref of lines, or scalar.
307 Will be placed inside an HTML <BODY> tag.
311 Email body (Text alternative). Arrayref of lines, or scalar.
313 =item custnum, msgnum (optional)
315 Customer and template numbers, passed through to send_email for logging.
319 Constructs a multipart message from text_body and html_body.
323 #false laziness w/FS::cust_bill::generate_email
331 my $me = '[FS::Misc::generate_email]';
333 my @fields = qw(from to bcc subject custnum msgnum);
335 @return{@fields} = @args{@fields};
337 warn "$me creating HTML/text multipart message"
340 $return{'nobody'} = 1;
342 my $alternative = build MIME::Entity
343 'Type' => 'multipart/alternative',
344 'Encoding' => '7bit',
345 'Disposition' => 'inline'
349 if ( ref($args{'text_body'}) eq 'ARRAY' ) {
350 $data = join("\n", @{ $args{'text_body'} });
352 $data = $args{'text_body'};
355 $alternative->attach(
356 'Type' => 'text/plain',
357 'Encoding' => 'quoted-printable',
358 #'Encoding' => '7bit',
360 'Disposition' => 'inline',
364 if ( ref($args{'html_body'}) eq 'ARRAY' ) {
365 @html_data = @{ $args{'html_body'} };
367 @html_data = split(/\n/, $args{'html_body'});
370 $alternative->attach(
371 'Type' => 'text/html',
372 'Encoding' => 'quoted-printable',
373 'Data' => [ '<html>',
376 ' '. encode_entities($return{'subject'}),
379 ' <body bgcolor="#ffffff">',
384 'Disposition' => 'inline',
385 #'Filename' => 'invoice.pdf',
388 #no other attachment:
390 # multipart/alternative
394 $return{'content-type'} = 'multipart/related';
395 $return{'mimeparts'} = [ $alternative ];
396 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
397 #$return{'disposition'} = 'inline';
403 =item process_send_email OPTION => VALUE ...
405 Takes arguments as per generate_email() and sends the message. This
406 will die on any error and can be used in the job queue.
410 sub process_send_email {
412 my $error = send_email(generate_email(%message));
413 die "$error\n" if $error;
417 =item process_send_generated_email OPTION => VALUE ...
419 Takes arguments as per send_email() and sends the message. This
420 will die on any error and can be used in the job queue.
424 sub process_send_generated_email {
426 my $error = send_email(%args);
427 die "$error\n" if $error;
431 =item send_fax OPTION => VALUE ...
435 I<dialstring> - (required) 10-digit phone number w/ area code
437 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
441 I<docfile> - (required) Filename of PostScript TIFF Class F document
443 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
452 die 'HylaFAX support has not been configured.'
453 unless $conf->exists('hylafax');
456 require Fax::Hylafax::Client;
460 if ($@ =~ /^Can't locate Fax.*/) {
461 die "You must have Fax::Hylafax::Client installed to use invoice faxing."
467 my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
469 die 'Called send_fax without a \'dialstring\'.'
470 unless exists($options{'dialstring'});
472 if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
473 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
474 my $fh = new File::Temp(
475 TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
478 ) or die "can't open temp file: $!\n";
480 $options{docfile} = $fh->filename;
482 print $fh @{$options{'docdata'}};
485 delete $options{'docdata'};
488 die 'Called send_fax without a \'docfile\' or \'docdata\'.'
489 unless exists($options{'docfile'});
491 #FIXME: Need to send canonical dialstring to HylaFAX, but this only
494 $options{'dialstring'} =~ s/[^\d\+]//g;
495 if ($options{'dialstring'} =~ /^\d{10}$/) {
496 $options{dialstring} = '+1' . $options{'dialstring'};
498 return 'Invalid dialstring ' . $options{'dialstring'} . '.';
501 my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
503 if ($faxjob->success) {
504 warn "Successfully queued fax to '$options{dialstring}' with jobid " .
509 return 'Error while sending FAX: ' . $faxjob->trace;
514 =item states_hash COUNTRY
516 Returns a list of key/value pairs containing state (or other sub-country
517 division) abbriviations and names.
521 use FS::Record qw(qsearch);
522 use Locale::SubCountry;
529 map { s/[\n\r]//g; $_; }
533 'table' => 'cust_main_county',
534 'hashref' => { 'country' => $country },
535 'extra_sql' => 'GROUP BY state',
538 #it could throw a fatal "Invalid country code" error (for example "AX")
539 my $subcountry = eval { new Locale::SubCountry($country) }
540 or return ( '', '(n/a)' );
542 #"i see your schwartz is as big as mine!"
543 map { ( $_->[0] => $_->[1] ) }
544 sort { $a->[1] cmp $b->[1] }
545 map { [ $_ => state_label($_, $subcountry) ] }
549 =item counties STATE COUNTRY
551 Returns a list of counties for this state and country.
556 my( $state, $country ) = @_;
558 map { $_ } #return num_counties($state, $country) unless wantarray;
559 sort map { s/[\n\r]//g; $_; }
562 'select' => 'DISTINCT county',
563 'table' => 'cust_main_county',
564 'hashref' => { 'state' => $state,
565 'country' => $country,
570 =item cities COUNTY STATE COUNTRY
572 Returns a list of cities for this county, state and country.
577 my( $county, $state, $country ) = @_;
579 map { $_ } #return num_cities($county, $state, $country) unless wantarray;
580 sort map { s/[\n\r]//g; $_; }
583 'select' => 'DISTINCT city',
584 'table' => 'cust_main_county',
585 'hashref' => { 'county' => $county,
587 'country' => $country,
592 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
597 my( $state, $country ) = @_;
599 unless ( ref($country) ) {
600 $country = eval { new Locale::SubCountry($country) }
605 # US kludge to avoid changing existing behaviour
606 # also we actually *use* the abbriviations...
607 my $full_name = $country->country_code eq 'US'
609 : $country->full_name($state);
611 $full_name = '' if $full_name eq 'unknown';
612 $full_name =~ s/\(see also.*\)\s*$//;
613 $full_name .= " ($state)" if $full_name;
615 $full_name || $state || '(n/a)';
621 Returns a hash reference of the accepted credit card types. Keys are shorter
622 identifiers and values are the longer strings used by the system (see
623 L<Business::CreditCard>).
630 my $conf = new FS::Conf;
633 #displayname #value (Business::CreditCard)
634 "VISA" => "VISA card",
635 "MasterCard" => "MasterCard",
636 "Discover" => "Discover card",
637 "American Express" => "American Express card",
638 "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
639 "enRoute" => "enRoute",
641 "BankCard" => "BankCard",
642 "Switch" => "Switch",
645 my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
646 if ( @conf_card_types ) {
647 #perhaps the hash is backwards for this, but this way works better for
648 #usage in selfservice
649 %card_types = map { $_ => $card_types{$_} }
652 grep { $card_types{$d} eq $_ } @conf_card_types
662 Returns a hash reference of allowed package billing frequencies.
667 tie my %freq, 'Tie::IxHash', (
668 '0' => '(no recurring fee)',
671 '2d' => 'every two days',
672 '3d' => 'every three days',
674 '2w' => 'biweekly (every 2 weeks)',
676 '45d' => 'every 45 days',
677 '2' => 'bimonthly (every 2 months)',
678 '3' => 'quarterly (every 3 months)',
679 '4' => 'every 4 months',
680 '137d' => 'every 4 1/2 months (137 days)',
681 '6' => 'semiannually (every 6 months)',
683 '13' => 'every 13 months (annually +1 month)',
684 '24' => 'biannually (every 2 years)',
685 '36' => 'triannually (every 3 years)',
686 '48' => '(every 4 years)',
687 '60' => '(every 5 years)',
688 '120' => '(every 10 years)',
693 =item generate_ps FILENAME
695 Returns an postscript rendition of the LaTex file, as a scalar.
696 FILENAME does not contain the .tex suffix and is unlinked by this function.
700 use String::ShellQuote;
705 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
710 system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
711 or die "dvips failed";
713 open(POSTSCRIPT, "<$file.ps")
714 or die "can't open $file.ps: $! (error in LaTeX template?)\n";
716 unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex")
717 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
721 if ( $conf->exists('lpr-postscript_prefix') ) {
722 my $prefix = $conf->config('lpr-postscript_prefix');
723 $ps .= eval qq("$prefix");
726 while (<POSTSCRIPT>) {
732 if ( $conf->exists('lpr-postscript_suffix') ) {
733 my $suffix = $conf->config('lpr-postscript_suffix');
734 $ps .= eval qq("$suffix");
741 =item generate_pdf FILENAME
743 Returns an PDF rendition of the LaTex file, as a scalar. FILENAME does not
744 contain the .tex suffix and is unlinked by this function.
748 use String::ShellQuote;
753 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
756 #system('pdflatex', "$file.tex");
757 #system('pdflatex', "$file.tex");
758 #! LaTeX Error: Unknown graphics extension: .eps.
762 my $sfile = shell_quote $file;
764 #system('dvipdf', "$file.dvi", "$file.pdf" );
766 "dvips -q -t letter -f $sfile.dvi ".
767 "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
770 or die "dvips | gs failed: $!";
772 open(PDF, "<$file.pdf")
773 or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
775 unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex")
776 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
792 #my $sfile = shell_quote $file;
796 '-interaction=batchmode',
797 '\AtBeginDocument{\RequirePackage{pslatex}}',
798 '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
799 '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
804 my $timeout = 30; #? should be more than enough
808 local($SIG{CHLD}) = sub {};
809 run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
810 or warn "bad exit status from pslatex pass $_\n";
814 return if -e "$file.dvi" && -s "$file.dvi";
815 die "pslatex $file.tex failed; see $file.log for details?\n";
819 =item do_print ARRAYREF [, OPTION => VALUE ... ]
821 Sends the lines in ARRAYREF to the printer.
823 Options available are:
829 Uses this agent's 'lpr' configuration setting override instead of the global
834 Uses this command instead of the configured lpr command (overrides both the
835 global value and agentnum).
840 my( $data, %opt ) = @_;
842 my $lpr = ( exists($opt{'lpr'}) && $opt{'lpr'} )
844 : $conf->config('lpr', $opt{'agentnum'} );
847 run3 $lpr, $data, \$outerr, \$outerr;
849 $outerr = ": $outerr" if length($outerr);
850 die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
855 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
857 Converts the filehandle referenced by FILEREF from fixed length record
858 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
859 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs. Each
860 should return the value to be substituted in place of its single argument.
862 Returns false on success or an error if one occurs.
867 my( $fhref, $countref, $lengths, $callbacks) = @_;
869 eval { require Text::CSV_XS; };
873 my $unpacker = new Text::CSV_XS;
875 my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
877 my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
878 my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
881 ) or return "can't open temp file: $!\n"
884 while ( defined(my $line=<$ofh>) ) {
890 return "unexpected input at line $$countref: $line".
891 " -- expected $total but received ". length($line)
892 unless length($line) == $total;
894 $unpacker->combine( map { my $i = $column++;
895 defined( $callbacks->[$i] )
896 ? &{ $callbacks->[$i] }( $_ )
898 } unpack( $template, $line )
900 or return "invalid data for CSV: ". $unpacker->error_input;
902 print $fh $unpacker->string(), "\n"
903 or return "can't write temp file: $!\n";
907 if ( $template ) { close $$fhref; $$fhref = $fh }
913 =item ocr_image IMAGE_SCALAR
915 Runs OCR on the provided image data and returns a list of text lines.
920 my $logo_data = shift;
922 #XXX use conf dir location from Makefile
923 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
924 my $fh = new File::Temp(
925 TEMPLATE => 'bizcard.XXXXXXXX',
926 SUFFIX => '.png', #XXX assuming, but should handle jpg, gif, etc. too
929 ) or die "can't open temp file: $!\n";
931 my $filename = $fh->filename;
933 print $fh $logo_data;
936 run( [qw(ocroscript recognize), $filename], '>'=>"$filename.hocr" )
937 or die "ocroscript recognize failed\n";
939 run( [qw(ocroscript hocr-to-text), "$filename.hocr"], '>pipe'=>\*OUT )
940 or die "ocroscript hocr-to-text failed\n";
942 my @lines = split(/\n/, <OUT> );
944 foreach (@lines) { s/\.c0m\s*$/.com/; }
957 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
959 L<Fax::Hylafax::Client>