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
12 #do NOT depend on any FS:: modules here, causes weird (sometimes unreproducable
13 #until on client machine) dependancy loops. put them in FS::Misc::Something
16 @ISA = qw( Exporter );
17 @EXPORT_OK = qw( send_email generate_email send_fax
18 states_hash counties cities state_label
21 generate_ps generate_pdf do_print
30 FS::Misc - Miscellaneous subroutines
34 use FS::Misc qw(send_email);
40 Miscellaneous subroutines. This module contains miscellaneous subroutines
41 called from multiple other modules. These are not OO or necessarily related,
42 but are collected here to eliminate code duplication.
48 =item send_email OPTION => VALUE ...
60 (required) comma-separated scalar or arrayref of recipients
68 (optional) MIME type for the body
72 (required unless I<nobody> is true) arrayref of body text lines
76 (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().
80 (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,
81 I<content-type>, if specified, overrides the default "multipart/mixed" for the outermost MIME container.
83 =item content-encoding
85 (optional) when using nobody, optional top-level MIME
86 encoding which, if specified, overrides the default "7bit".
90 (optional) type parameter for multipart/related messages
99 use Email::Sender::Simple qw(sendmail);
100 use Email::Sender::Transport::SMTP;
101 use Email::Sender::Transport::SMTP::TLS;
104 FS::UID->install_callback( sub {
105 $conf = new FS::Conf;
111 my %doptions = %options;
112 $doptions{'body'} = '(full body not shown in debug)';
113 warn "FS::Misc::send_email called with options:\n ". Dumper(\%doptions);
114 # join("\n", map { " $_: ". $options{$_} } keys %options ). "\n"
117 my @to = ref($options{to}) ? @{ $options{to} } : ( $options{to} );
121 if ( $options{'nobody'} ) {
123 croak "'mimeparts' option required when 'nobody' option given\n"
124 unless $options{'mimeparts'};
126 @mimeparts = @{$options{'mimeparts'}};
129 'Type' => ( $options{'content-type'} || 'multipart/mixed' ),
130 'Encoding' => ( $options{'content-encoding'} || '7bit' ),
135 @mimeparts = @{$options{'mimeparts'}}
136 if ref($options{'mimeparts'}) eq 'ARRAY';
138 if (scalar(@mimeparts)) {
141 'Type' => 'multipart/mixed',
142 'Encoding' => '7bit',
145 unshift @mimeparts, {
146 'Type' => ( $options{'content-type'} || 'text/plain' ),
147 'Data' => $options{'body'},
148 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
149 'Disposition' => 'inline',
155 'Type' => ( $options{'content-type'} || 'text/plain' ),
156 'Data' => $options{'body'},
157 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
165 if ( $options{'from'} =~ /\@([\w\.\-]+)/ ) {
168 warn 'no domain found in invoice from address '. $options{'from'}.
169 '; constructing Message-ID (and saying HELO) @example.com';
170 $domain = 'example.com';
172 my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
174 my $message = MIME::Entity->build(
175 'From' => $options{'from'},
176 'To' => join(', ', @to),
177 'Sender' => $options{'from'},
178 'Reply-To' => $options{'from'},
179 'Date' => time2str("%a, %d %b %Y %X %z", time),
180 'Subject' => $options{'subject'},
181 'Message-ID' => "<$message_id>",
185 if ( $options{'type'} ) {
186 #false laziness w/cust_bill::generate_email
187 $message->head->replace('Content-type',
189 '; boundary="'. $message->head->multipart_boundary. '"'.
190 '; type='. $options{'type'}
194 foreach my $part (@mimeparts) {
196 if ( UNIVERSAL::isa($part, 'MIME::Entity') ) {
198 warn "attaching MIME part from MIME::Entity object\n"
200 $message->add_part($part);
202 } elsif ( ref($part) eq 'HASH' ) {
204 warn "attaching MIME part from hashref:\n".
205 join("\n", map " $_: ".$part->{$_}, keys %$part ). "\n"
207 $message->attach(%$part);
210 croak "mimepart $part isn't a hashref or MIME::Entity object!";
217 my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
221 my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
222 $smtp_opt{'port'} = $port;
225 if ( defined($enc) && $enc eq 'starttls' ) {
226 $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
227 $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
229 if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
230 $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
232 $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
233 $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
236 push @to, $options{bcc} if defined($options{bcc});
237 local $@; # just in case
238 eval { sendmail($message, { transport => $transport,
239 from => $options{from},
242 if(ref($@) and $@->isa('Email::Sender::Failure')) {
243 return ($@->code ? $@->code.' ' : '').$@->message
250 =item generate_email OPTION => VALUE ...
258 Sender address, required
262 Recipient address, required
266 Blind copy address, optional
270 email subject, required
274 Email body (HTML alternative). Arrayref of lines, or scalar.
276 Will be placed inside an HTML <BODY> tag.
280 Email body (Text alternative). Arrayref of lines, or scalar.
284 Constructs a multipart message from text_body and html_body.
288 #false laziness w/FS::cust_bill::generate_email
296 my $me = '[FS::Misc::generate_email]';
299 'from' => $args{'from'},
301 'bcc' => $args{'bcc'},
302 'subject' => $args{'subject'},
305 #if (ref($args{'to'}) eq 'ARRAY') {
306 # $return{'to'} = $args{'to'};
308 # $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
309 # $self->cust_main->invoicing_list
313 warn "$me creating HTML/text multipart message"
316 $return{'nobody'} = 1;
318 my $alternative = build MIME::Entity
319 'Type' => 'multipart/alternative',
320 'Encoding' => '7bit',
321 'Disposition' => 'inline'
325 if ( ref($args{'text_body'}) eq 'ARRAY' ) {
326 $data = join("\n", @{ $args{'text_body'} });
328 $data = $args{'text_body'};
331 $alternative->attach(
332 'Type' => 'text/plain',
333 #'Encoding' => 'quoted-printable',
334 'Encoding' => '7bit',
336 'Disposition' => 'inline',
340 if ( ref($args{'html_body'}) eq 'ARRAY' ) {
341 @html_data = @{ $args{'html_body'} };
343 @html_data = split(/\n/, $args{'html_body'});
346 $alternative->attach(
347 'Type' => 'text/html',
348 'Encoding' => 'quoted-printable',
349 'Data' => [ '<html>',
352 ' '. encode_entities($return{'subject'}),
355 ' <body bgcolor="#e8e8e8">',
360 'Disposition' => 'inline',
361 #'Filename' => 'invoice.pdf',
364 #no other attachment:
366 # multipart/alternative
370 $return{'content-type'} = 'multipart/related';
371 $return{'mimeparts'} = [ $alternative ];
372 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
373 #$return{'disposition'} = 'inline';
379 =item process_send_email OPTION => VALUE ...
381 Takes arguments as per generate_email() and sends the message. This
382 will die on any error and can be used in the job queue.
386 sub process_send_email {
388 my $error = send_email(generate_email(%message));
389 die "$error\n" if $error;
393 =item send_fax OPTION => VALUE ...
397 I<dialstring> - (required) 10-digit phone number w/ area code
399 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
403 I<docfile> - (required) Filename of PostScript TIFF Class F document
405 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
414 die 'HylaFAX support has not been configured.'
415 unless $conf->exists('hylafax');
418 require Fax::Hylafax::Client;
422 if ($@ =~ /^Can't locate Fax.*/) {
423 die "You must have Fax::Hylafax::Client installed to use invoice faxing."
429 my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
431 die 'Called send_fax without a \'dialstring\'.'
432 unless exists($options{'dialstring'});
434 if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
435 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
436 my $fh = new File::Temp(
437 TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
440 ) or die "can't open temp file: $!\n";
442 $options{docfile} = $fh->filename;
444 print $fh @{$options{'docdata'}};
447 delete $options{'docdata'};
450 die 'Called send_fax without a \'docfile\' or \'docdata\'.'
451 unless exists($options{'docfile'});
453 #FIXME: Need to send canonical dialstring to HylaFAX, but this only
456 $options{'dialstring'} =~ s/[^\d\+]//g;
457 if ($options{'dialstring'} =~ /^\d{10}$/) {
458 $options{dialstring} = '+1' . $options{'dialstring'};
460 return 'Invalid dialstring ' . $options{'dialstring'} . '.';
463 my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
465 if ($faxjob->success) {
466 warn "Successfully queued fax to '$options{dialstring}' with jobid " .
471 return 'Error while sending FAX: ' . $faxjob->trace;
476 =item states_hash COUNTRY
478 Returns a list of key/value pairs containing state (or other sub-country
479 division) abbriviations and names.
483 use FS::Record qw(qsearch);
484 use Locale::SubCountry;
491 map { s/[\n\r]//g; $_; }
495 'table' => 'cust_main_county',
496 'hashref' => { 'country' => $country },
497 'extra_sql' => 'GROUP BY state',
500 #it could throw a fatal "Invalid country code" error (for example "AX")
501 my $subcountry = eval { new Locale::SubCountry($country) }
502 or return ( '', '(n/a)' );
504 #"i see your schwartz is as big as mine!"
505 map { ( $_->[0] => $_->[1] ) }
506 sort { $a->[1] cmp $b->[1] }
507 map { [ $_ => state_label($_, $subcountry) ] }
511 =item counties STATE COUNTRY
513 Returns a list of counties for this state and country.
518 my( $state, $country ) = @_;
520 map { $_ } #return num_counties($state, $country) unless wantarray;
521 sort map { s/[\n\r]//g; $_; }
524 'select' => 'DISTINCT county',
525 'table' => 'cust_main_county',
526 'hashref' => { 'state' => $state,
527 'country' => $country,
532 =item cities COUNTY STATE COUNTRY
534 Returns a list of cities for this county, state and country.
539 my( $county, $state, $country ) = @_;
541 map { $_ } #return num_cities($county, $state, $country) unless wantarray;
542 sort map { s/[\n\r]//g; $_; }
545 'select' => 'DISTINCT city',
546 'table' => 'cust_main_county',
547 'hashref' => { 'county' => $county,
549 'country' => $country,
554 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
559 my( $state, $country ) = @_;
561 unless ( ref($country) ) {
562 $country = eval { new Locale::SubCountry($country) }
567 # US kludge to avoid changing existing behaviour
568 # also we actually *use* the abbriviations...
569 my $full_name = $country->country_code eq 'US'
571 : $country->full_name($state);
573 $full_name = '' if $full_name eq 'unknown';
574 $full_name =~ s/\(see also.*\)\s*$//;
575 $full_name .= " ($state)" if $full_name;
577 $full_name || $state || '(n/a)';
583 Returns a hash reference of the accepted credit card types. Keys are shorter
584 identifiers and values are the longer strings used by the system (see
585 L<Business::CreditCard>).
592 my $conf = new FS::Conf;
595 #displayname #value (Business::CreditCard)
596 "VISA" => "VISA card",
597 "MasterCard" => "MasterCard",
598 "Discover" => "Discover card",
599 "American Express" => "American Express card",
600 "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
601 "enRoute" => "enRoute",
603 "BankCard" => "BankCard",
604 "Switch" => "Switch",
607 my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
608 if ( @conf_card_types ) {
609 #perhaps the hash is backwards for this, but this way works better for
610 #usage in selfservice
611 %card_types = map { $_ => $card_types{$_} }
614 grep { $card_types{$d} eq $_ } @conf_card_types
624 Returns a hash reference of allowed package billing frequencies.
629 tie my %freq, 'Tie::IxHash', (
630 '0' => '(no recurring fee)',
633 '2d' => 'every two days',
634 '3d' => 'every three days',
636 '2w' => 'biweekly (every 2 weeks)',
638 '45d' => 'every 45 days',
639 '2' => 'bimonthly (every 2 months)',
640 '3' => 'quarterly (every 3 months)',
641 '4' => 'every 4 months',
642 '137d' => 'every 4 1/2 months (137 days)',
643 '6' => 'semiannually (every 6 months)',
645 '13' => 'every 13 months (annually +1 month)',
646 '24' => 'biannually (every 2 years)',
647 '36' => 'triannually (every 3 years)',
648 '48' => '(every 4 years)',
649 '60' => '(every 5 years)',
650 '120' => '(every 10 years)',
655 =item generate_ps FILENAME
657 Returns an postscript rendition of the LaTex file, as a scalar.
658 FILENAME does not contain the .tex suffix and is unlinked by this function.
662 use String::ShellQuote;
667 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
672 system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
673 or die "dvips failed";
675 open(POSTSCRIPT, "<$file.ps")
676 or die "can't open $file.ps: $! (error in LaTeX template?)\n";
678 unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
682 if ( $conf->exists('lpr-postscript_prefix') ) {
683 my $prefix = $conf->config('lpr-postscript_prefix');
684 $ps .= eval qq("$prefix");
687 while (<POSTSCRIPT>) {
693 if ( $conf->exists('lpr-postscript_suffix') ) {
694 my $suffix = $conf->config('lpr-postscript_suffix');
695 $ps .= eval qq("$suffix");
702 =item generate_pdf FILENAME
704 Returns an PDF rendition of the LaTex file, as a scalar. FILENAME does not
705 contain the .tex suffix and is unlinked by this function.
709 use String::ShellQuote;
714 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
717 #system('pdflatex', "$file.tex");
718 #system('pdflatex', "$file.tex");
719 #! LaTeX Error: Unknown graphics extension: .eps.
723 my $sfile = shell_quote $file;
725 #system('dvipdf', "$file.dvi", "$file.pdf" );
727 "dvips -q -t letter -f $sfile.dvi ".
728 "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
731 or die "dvips | gs failed: $!";
733 open(PDF, "<$file.pdf")
734 or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
736 unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
752 #my $sfile = shell_quote $file;
756 '-interaction=batchmode',
757 '\AtBeginDocument{\RequirePackage{pslatex}}',
758 '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
759 '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
764 my $timeout = 30; #? should be more than enough
768 local($SIG{CHLD}) = sub {};
769 run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
770 or die "pslatex $file.tex failed; see $file.log for details?\n";
778 Sends the lines in ARRAYREF to the printer.
785 my $lpr = $conf->config('lpr');
788 run3 $lpr, $data, \$outerr, \$outerr;
790 $outerr = ": $outerr" if length($outerr);
791 die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
796 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
798 Converts the filehandle referenced by FILEREF from fixed length record
799 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
800 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs. Each
801 should return the value to be substituted in place of its single argument.
803 Returns false on success or an error if one occurs.
808 my( $fhref, $countref, $lengths, $callbacks) = @_;
810 eval { require Text::CSV_XS; };
814 my $unpacker = new Text::CSV_XS;
816 my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
818 my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
819 my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
822 ) or return "can't open temp file: $!\n"
825 while ( defined(my $line=<$ofh>) ) {
831 return "unexpected input at line $$countref: $line".
832 " -- expected $total but received ". length($line)
833 unless length($line) == $total;
835 $unpacker->combine( map { my $i = $column++;
836 defined( $callbacks->[$i] )
837 ? &{ $callbacks->[$i] }( $_ )
839 } unpack( $template, $line )
841 or return "invalid data for CSV: ". $unpacker->error_input;
843 print $fh $unpacker->string(), "\n"
844 or return "can't write temp file: $!\n";
848 if ( $template ) { close $$fhref; $$fhref = $fh }
854 =item ocr_image IMAGE_SCALAR
856 Runs OCR on the provided image data and returns a list of text lines.
861 my $logo_data = shift;
863 #XXX use conf dir location from Makefile
864 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
865 my $fh = new File::Temp(
866 TEMPLATE => 'bizcard.XXXXXXXX',
867 SUFFIX => '.png', #XXX assuming, but should handle jpg, gif, etc. too
870 ) or die "can't open temp file: $!\n";
872 my $filename = $fh->filename;
874 print $fh $logo_data;
877 run( [qw(ocroscript recognize), $filename], '>'=>"$filename.hocr" )
878 or die "ocroscript recognize failed\n";
880 run( [qw(ocroscript hocr-to-text), "$filename.hocr"], '>pipe'=>\*OUT )
881 or die "ocroscript hocr-to-text failed\n";
883 my @lines = split(/\n/, <OUT> );
885 foreach (@lines) { s/\.c0m\s*$/.com/; }
898 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
900 L<Fax::Hylafax::Client>