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' ),
174 my $from = $options{from};
175 $from =~ s/^\s*//; $from =~ s/\s*$//;
176 if ( $from =~ /^(.*)\s*<(.*@.*)>$/ ) {
182 if ( $from =~ /\@([\w\.\-]+)/ ) {
185 warn 'no domain found in invoice from address '. $options{'from'}.
186 '; constructing Message-ID (and saying HELO) @example.com';
187 $domain = 'example.com';
189 my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
192 my $message = MIME::Entity->build(
193 'From' => $options{'from'},
194 'To' => join(', ', @to),
195 'Sender' => $options{'from'},
196 'Reply-To' => $options{'from'},
197 'Date' => time2str("%a, %d %b %Y %X %z", $time),
198 'Subject' => Encode::encode('MIME-Header', $options{'subject'}),
199 'Message-ID' => "<$message_id>",
203 if ( $options{'type'} ) {
204 #false laziness w/cust_bill::generate_email
205 $message->head->replace('Content-type',
207 '; boundary="'. $message->head->multipart_boundary. '"'.
208 '; type='. $options{'type'}
212 foreach my $part (@mimeparts) {
214 if ( UNIVERSAL::isa($part, 'MIME::Entity') ) {
216 warn "attaching MIME part from MIME::Entity object\n"
218 $message->add_part($part);
220 } elsif ( ref($part) eq 'HASH' ) {
222 warn "attaching MIME part from hashref:\n".
223 join("\n", map " $_: ".$part->{$_}, keys %$part ). "\n"
225 $message->attach(%$part);
228 croak "mimepart $part isn't a hashref or MIME::Entity object!";
235 my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
239 my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
240 $smtp_opt{'port'} = $port;
243 if ( defined($enc) && $enc eq 'starttls' ) {
244 $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
245 $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
247 if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
248 $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
250 $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
251 $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
254 push @to, $options{bcc} if defined($options{bcc});
255 local $@; # just in case
256 eval { sendmail($message, { transport => $transport,
261 if(ref($@) and $@->isa('Email::Sender::Failure')) {
262 $error = $@->code.' ' if $@->code;
263 $error .= $@->message;
270 if ( $conf->exists('log_sent_mail') ) {
271 my $cust_msg = FS::cust_msg->new({
272 'env_from' => $options{'from'},
273 'env_to' => join(', ', @to),
274 'header' => $message->header_as_string,
275 'body' => $message->body_as_string,
278 'custnum' => $options{'custnum'},
279 'msgnum' => $options{'msgnum'},
280 'status' => ($error ? 'failed' : 'sent'),
281 'msgtype' => $options{'msgtype'},
283 $cust_msg->insert; # ignore errors
289 =item generate_email OPTION => VALUE ...
297 Sender address, required
301 Recipient address, required
305 Blind copy address, optional
309 email subject, required
313 Email body (HTML alternative). Arrayref of lines, or scalar.
315 Will be placed inside an HTML <BODY> tag.
319 Email body (Text alternative). Arrayref of lines, or scalar.
321 =item custnum, msgnum (optional)
323 Customer and template numbers, passed through to send_email for logging.
327 Constructs a multipart message from text_body and html_body.
331 #false laziness w/FS::cust_bill::generate_email
339 my $me = '[FS::Misc::generate_email]';
341 my @fields = qw(from to bcc subject custnum msgnum msgtype);
343 @return{@fields} = @args{@fields};
345 warn "$me creating HTML/text multipart message"
348 $return{'nobody'} = 1;
350 my $alternative = build MIME::Entity
351 'Type' => 'multipart/alternative',
352 'Encoding' => '7bit',
353 'Disposition' => 'inline'
357 if ( ref($args{'text_body'}) eq 'ARRAY' ) {
358 $data = join("\n", @{ $args{'text_body'} });
360 $data = $args{'text_body'};
363 $alternative->attach(
364 'Type' => 'text/plain',
365 'Encoding' => 'quoted-printable',
366 #'Encoding' => '7bit',
368 'Disposition' => 'inline',
372 if ( ref($args{'html_body'}) eq 'ARRAY' ) {
373 @html_data = @{ $args{'html_body'} };
375 @html_data = split(/\n/, $args{'html_body'});
378 $alternative->attach(
379 'Type' => 'text/html',
380 'Encoding' => 'quoted-printable',
381 'Data' => [ '<html>',
384 ' '. encode_entities($return{'subject'}),
387 ' <body bgcolor="#ffffff">',
392 'Disposition' => 'inline',
393 #'Filename' => 'invoice.pdf',
396 #no other attachment:
398 # multipart/alternative
402 $return{'content-type'} = 'multipart/related';
403 $return{'mimeparts'} = [ $alternative ];
404 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
405 #$return{'disposition'} = 'inline';
411 =item process_send_email OPTION => VALUE ...
413 Takes arguments as per generate_email() and sends the message. This
414 will die on any error and can be used in the job queue.
418 sub process_send_email {
420 my $error = send_email(generate_email(%message));
421 die "$error\n" if $error;
425 =item process_send_generated_email OPTION => VALUE ...
427 Takes arguments as per send_email() and sends the message. This
428 will die on any error and can be used in the job queue.
432 sub process_send_generated_email {
434 my $error = send_email(%args);
435 die "$error\n" if $error;
439 =item send_fax OPTION => VALUE ...
443 I<dialstring> - (required) 10-digit phone number w/ area code
445 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
449 I<docfile> - (required) Filename of PostScript TIFF Class F document
451 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
460 die 'HylaFAX support has not been configured.'
461 unless $conf->exists('hylafax');
464 require Fax::Hylafax::Client;
468 if ($@ =~ /^Can't locate Fax.*/) {
469 die "You must have Fax::Hylafax::Client installed to use invoice faxing."
475 my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
477 die 'Called send_fax without a \'dialstring\'.'
478 unless exists($options{'dialstring'});
480 if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
481 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
482 my $fh = new File::Temp(
483 TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
486 ) or die "can't open temp file: $!\n";
488 $options{docfile} = $fh->filename;
490 print $fh @{$options{'docdata'}};
493 delete $options{'docdata'};
496 die 'Called send_fax without a \'docfile\' or \'docdata\'.'
497 unless exists($options{'docfile'});
499 #FIXME: Need to send canonical dialstring to HylaFAX, but this only
502 $options{'dialstring'} =~ s/[^\d\+]//g;
503 if ($options{'dialstring'} =~ /^\d{10}$/) {
504 $options{dialstring} = '+1' . $options{'dialstring'};
506 return 'Invalid dialstring ' . $options{'dialstring'} . '.';
509 my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
511 if ($faxjob->success) {
512 warn "Successfully queued fax to '$options{dialstring}' with jobid " .
517 return 'Error while sending FAX: ' . $faxjob->trace;
522 =item states_hash COUNTRY
524 Returns a list of key/value pairs containing state (or other sub-country
525 division) abbriviations and names.
529 use FS::Record qw(qsearch);
530 use Locale::SubCountry;
537 map { s/[\n\r]//g; $_; }
541 'table' => 'cust_main_county',
542 'hashref' => { 'country' => $country },
543 'extra_sql' => 'GROUP BY state',
546 #it could throw a fatal "Invalid country code" error (for example "AX")
547 my $subcountry = eval { new Locale::SubCountry($country) }
548 or return (); # ( '', '(n/a)' );
550 #"i see your schwartz is as big as mine!"
551 map { ( $_->[0] => $_->[1] ) }
552 sort { $a->[1] cmp $b->[1] }
553 map { [ $_ => state_label($_, $subcountry) ] }
557 =item counties STATE COUNTRY
559 Returns a list of counties for this state and country.
564 my( $state, $country ) = @_;
566 map { $_ } #return num_counties($state, $country) unless wantarray;
567 sort map { s/[\n\r]//g; $_; }
570 'select' => 'DISTINCT county',
571 'table' => 'cust_main_county',
572 'hashref' => { 'state' => $state,
573 'country' => $country,
578 =item cities COUNTY STATE COUNTRY
580 Returns a list of cities for this county, state and country.
585 my( $county, $state, $country ) = @_;
587 map { $_ } #return num_cities($county, $state, $country) unless wantarray;
588 sort map { s/[\n\r]//g; $_; }
591 'select' => 'DISTINCT city',
592 'table' => 'cust_main_county',
593 'hashref' => { 'county' => $county,
595 'country' => $country,
600 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
605 my( $state, $country ) = @_;
607 unless ( ref($country) ) {
608 $country = eval { new Locale::SubCountry($country) }
613 # US kludge to avoid changing existing behaviour
614 # also we actually *use* the abbriviations...
615 my $full_name = $country->country_code eq 'US'
617 : $country->full_name($state);
619 $full_name = '' if $full_name eq 'unknown';
620 $full_name =~ s/\(see also.*\)\s*$//;
621 $full_name .= " ($state)" if $full_name;
623 $full_name || $state || '(n/a)';
629 Returns a hash reference of the accepted credit card types. Keys are shorter
630 identifiers and values are the longer strings used by the system (see
631 L<Business::CreditCard>).
638 my $conf = new FS::Conf;
641 #displayname #value (Business::CreditCard)
642 "VISA" => "VISA card",
643 "MasterCard" => "MasterCard",
644 "Discover" => "Discover card",
645 "American Express" => "American Express card",
646 "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
647 "enRoute" => "enRoute",
649 "BankCard" => "BankCard",
650 "Switch" => "Switch",
653 my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
654 if ( @conf_card_types ) {
655 #perhaps the hash is backwards for this, but this way works better for
656 #usage in selfservice
657 %card_types = map { $_ => $card_types{$_} }
660 grep { $card_types{$d} eq $_ } @conf_card_types
670 Returns a hash reference of allowed package billing frequencies.
675 tie my %freq, 'Tie::IxHash', (
676 '0' => '(no recurring fee)',
679 '2d' => 'every two days',
680 '3d' => 'every three days',
682 '2w' => 'biweekly (every 2 weeks)',
684 '45d' => 'every 45 days',
685 '2' => 'bimonthly (every 2 months)',
686 '3' => 'quarterly (every 3 months)',
687 '4' => 'every 4 months',
688 '137d' => 'every 4 1/2 months (137 days)',
689 '6' => 'semiannually (every 6 months)',
691 '13' => 'every 13 months (annually +1 month)',
692 '24' => 'biannually (every 2 years)',
693 '36' => 'triannually (every 3 years)',
694 '48' => '(every 4 years)',
695 '60' => '(every 5 years)',
696 '120' => '(every 10 years)',
701 =item generate_ps FILENAME
703 Returns an postscript rendition of the LaTex file, as a scalar.
704 FILENAME does not contain the .tex suffix and is unlinked by this function.
708 use String::ShellQuote;
713 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
718 my $papersize = $conf->config('papersize') || 'letter';
720 system('dvips', '-q', '-t', $papersize, "$file.dvi", '-o', "$file.ps" ) == 0
721 or die "dvips failed";
723 open(POSTSCRIPT, "<$file.ps")
724 or die "can't open $file.ps: $! (error in LaTeX template?)\n";
726 unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex")
727 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
731 if ( $conf->exists('lpr-postscript_prefix') ) {
732 my $prefix = $conf->config('lpr-postscript_prefix');
733 $ps .= eval qq("$prefix");
736 while (<POSTSCRIPT>) {
742 if ( $conf->exists('lpr-postscript_suffix') ) {
743 my $suffix = $conf->config('lpr-postscript_suffix');
744 $ps .= eval qq("$suffix");
751 =item generate_pdf FILENAME
753 Returns an PDF rendition of the LaTex file, as a scalar. FILENAME does not
754 contain the .tex suffix and is unlinked by this function.
758 use String::ShellQuote;
763 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
766 #system('pdflatex', "$file.tex");
767 #system('pdflatex', "$file.tex");
768 #! LaTeX Error: Unknown graphics extension: .eps.
772 my $sfile = shell_quote $file;
774 #system('dvipdf', "$file.dvi", "$file.pdf" );
775 my $papersize = $conf->config('papersize') || 'letter';
778 "dvips -q -f $sfile.dvi -t $papersize ".
779 "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
782 or die "dvips | gs failed: $!";
784 open(PDF, "<$file.pdf")
785 or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
787 unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex")
788 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
804 #my $sfile = shell_quote $file;
808 '-interaction=batchmode',
809 '\AtBeginDocument{\RequirePackage{pslatex}}',
810 '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
811 '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
816 my $timeout = 30; #? should be more than enough
820 local($SIG{CHLD}) = sub {};
821 run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
822 or warn "bad exit status from pslatex pass $_\n";
826 return if -e "$file.dvi" && -s "$file.dvi";
827 die "pslatex $file.tex failed; see $file.log for details?\n";
831 =item do_print ARRAYREF [, OPTION => VALUE ... ]
833 Sends the lines in ARRAYREF to the printer.
835 Options available are:
841 Uses this agent's 'lpr' configuration setting override instead of the global
846 Uses this command instead of the configured lpr command (overrides both the
847 global value and agentnum).
852 my( $data, %opt ) = @_;
854 my $lpr = ( exists($opt{'lpr'}) && $opt{'lpr'} )
856 : $conf->config('lpr', $opt{'agentnum'} );
859 run3 $lpr, $data, \$outerr, \$outerr;
861 $outerr = ": $outerr" if length($outerr);
862 die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
867 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
869 Converts the filehandle referenced by FILEREF from fixed length record
870 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
871 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs. Each
872 should return the value to be substituted in place of its single argument.
874 Returns false on success or an error if one occurs.
879 my( $fhref, $countref, $lengths, $callbacks) = @_;
881 eval { require Text::CSV_XS; };
885 my $unpacker = new Text::CSV_XS;
887 my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
889 my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
890 my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
893 ) or return "can't open temp file: $!\n"
896 while ( defined(my $line=<$ofh>) ) {
902 return "unexpected input at line $$countref: $line".
903 " -- expected $total but received ". length($line)
904 unless length($line) == $total;
906 $unpacker->combine( map { my $i = $column++;
907 defined( $callbacks->[$i] )
908 ? &{ $callbacks->[$i] }( $_ )
910 } unpack( $template, $line )
912 or return "invalid data for CSV: ". $unpacker->error_input;
914 print $fh $unpacker->string(), "\n"
915 or return "can't write temp file: $!\n";
919 if ( $template ) { close $$fhref; $$fhref = $fh }
925 =item ocr_image IMAGE_SCALAR
927 Runs OCR on the provided image data and returns a list of text lines.
932 my $logo_data = shift;
934 #XXX use conf dir location from Makefile
935 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
936 my $fh = new File::Temp(
937 TEMPLATE => 'bizcard.XXXXXXXX',
938 SUFFIX => '.png', #XXX assuming, but should handle jpg, gif, etc. too
941 ) or die "can't open temp file: $!\n";
943 my $filename = $fh->filename;
945 print $fh $logo_data;
948 run( [qw(ocroscript recognize), $filename], '>'=>"$filename.hocr" )
949 or die "ocroscript recognize failed\n";
951 run( [qw(ocroscript hocr-to-text), "$filename.hocr"], '>pipe'=>\*OUT )
952 or die "ocroscript hocr-to-text failed\n";
954 my @lines = split(/\n/, <OUT> );
956 foreach (@lines) { s/\.c0m\s*$/.com/; }
969 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
971 L<Fax::Hylafax::Client>