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
32 FS::Misc - Miscellaneous subroutines
36 use FS::Misc qw(send_email);
42 Miscellaneous subroutines. This module contains miscellaneous subroutines
43 called from multiple other modules. These are not OO or necessarily related,
44 but are collected here to eliminate code duplication.
50 =item send_email OPTION => VALUE ...
62 (required) comma-separated scalar or arrayref of recipients
70 (optional) MIME type for the body
74 (required unless I<nobody> is true) arrayref of body text lines
78 (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().
82 (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,
83 I<content-type>, if specified, overrides the default "multipart/mixed" for the outermost MIME container.
85 =item content-encoding
87 (optional) when using nobody, optional top-level MIME
88 encoding which, if specified, overrides the default "7bit".
92 (optional) type parameter for multipart/related messages
96 (optional) L<FS::cust_main> key; if passed, the message will be logged
97 (if logging is enabled) with this custnum.
101 (optional) L<FS::msg_template> key, for logging.
107 use vars qw( $conf );
110 use Email::Sender::Simple qw(sendmail);
111 use Email::Sender::Transport::SMTP;
112 use Email::Sender::Transport::SMTP::TLS 0.11;
115 FS::UID->install_callback( sub {
116 $conf = new FS::Conf;
122 my %doptions = %options;
123 $doptions{'body'} = '(full body not shown in debug)';
124 warn "FS::Misc::send_email called with options:\n ". Dumper(\%doptions);
125 # join("\n", map { " $_: ". $options{$_} } keys %options ). "\n"
128 my @to = ref($options{to}) ? @{ $options{to} } : ( $options{to} );
132 if ( $options{'nobody'} ) {
134 croak "'mimeparts' option required when 'nobody' option given\n"
135 unless $options{'mimeparts'};
137 @mimeparts = @{$options{'mimeparts'}};
140 'Type' => ( $options{'content-type'} || 'multipart/mixed' ),
141 'Encoding' => ( $options{'content-encoding'} || '7bit' ),
146 @mimeparts = @{$options{'mimeparts'}}
147 if ref($options{'mimeparts'}) eq 'ARRAY';
149 if (scalar(@mimeparts)) {
152 'Type' => 'multipart/mixed',
153 'Encoding' => '7bit',
156 unshift @mimeparts, {
157 'Type' => ( $options{'content-type'} || 'text/plain' ),
158 'Charset' => 'UTF-8',
159 'Data' => $options{'body'},
160 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
161 'Disposition' => 'inline',
167 'Type' => ( $options{'content-type'} || 'text/plain' ),
168 'Data' => $options{'body'},
169 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
170 'Charset' => 'UTF-8',
177 my $from = $options{from};
178 $from =~ s/^\s*//; $from =~ s/\s*$//;
179 if ( $from =~ /^(.*)\s*<(.*@.*)>$/ ) {
185 if ( $from =~ /\@([\w\.\-]+)/ ) {
188 warn 'no domain found in invoice from address '. $options{'from'}.
189 '; constructing Message-ID (and saying HELO) @example.com';
190 $domain = 'example.com';
192 my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
195 my $message = MIME::Entity->build(
196 'From' => $options{'from'},
197 'To' => join(', ', @to),
198 'Sender' => $options{'from'},
199 'Reply-To' => $options{'from'},
200 'Date' => time2str("%a, %d %b %Y %X %z", $time),
201 'Subject' => Encode::encode('MIME-Header', $options{'subject'}),
202 'Message-ID' => "<$message_id>",
206 if ( $options{'type'} ) {
207 #false laziness w/cust_bill::generate_email
208 $message->head->replace('Content-type',
210 '; boundary="'. $message->head->multipart_boundary. '"'.
211 '; type='. $options{'type'}
215 foreach my $part (@mimeparts) {
217 if ( UNIVERSAL::isa($part, 'MIME::Entity') ) {
219 warn "attaching MIME part from MIME::Entity object\n"
221 $message->add_part($part);
223 } elsif ( ref($part) eq 'HASH' ) {
225 warn "attaching MIME part from hashref:\n".
226 join("\n", map " $_: ".$part->{$_}, keys %$part ). "\n"
228 $message->attach(%$part);
231 croak "mimepart $part isn't a hashref or MIME::Entity object!";
238 my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
242 my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
243 $smtp_opt{'port'} = $port;
246 if ( defined($enc) && $enc eq 'starttls' ) {
247 $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
248 $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
250 if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
251 $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
253 $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
254 $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
257 push @to, $options{bcc} if defined($options{bcc});
258 local $@; # just in case
259 eval { sendmail($message, { transport => $transport,
264 if(ref($@) and $@->isa('Email::Sender::Failure')) {
265 $error = $@->code.' ' if $@->code;
266 $error .= $@->message;
273 if ( $conf->exists('log_sent_mail') ) {
274 my $cust_msg = FS::cust_msg->new({
275 'env_from' => $options{'from'},
276 'env_to' => join(', ', @to),
277 'header' => $message->header_as_string,
278 'body' => $message->body_as_string,
281 'custnum' => $options{'custnum'},
282 'msgnum' => $options{'msgnum'},
283 'status' => ($error ? 'failed' : 'sent'),
284 'msgtype' => $options{'msgtype'},
286 $cust_msg->insert; # ignore errors
292 =item generate_email OPTION => VALUE ...
300 Sender address, required
304 Recipient address, required
308 Blind copy address, optional
312 email subject, required
316 Email body (HTML alternative). Arrayref of lines, or scalar.
318 Will be placed inside an HTML <BODY> tag.
322 Email body (Text alternative). Arrayref of lines, or scalar.
324 =item custnum, msgnum (optional)
326 Customer and template numbers, passed through to send_email for logging.
330 Constructs a multipart message from text_body and html_body.
334 #false laziness w/FS::cust_bill::generate_email
342 my $me = '[FS::Misc::generate_email]';
344 my @fields = qw(from to bcc subject custnum msgnum msgtype);
346 @return{@fields} = @args{@fields};
348 warn "$me creating HTML/text multipart message"
351 $return{'nobody'} = 1;
353 my $alternative = build MIME::Entity
354 'Type' => 'multipart/alternative',
355 'Encoding' => '7bit',
356 'Disposition' => 'inline'
360 if ( ref($args{'text_body'}) eq 'ARRAY' ) {
361 $data = join("\n", @{ $args{'text_body'} });
363 $data = $args{'text_body'};
366 $alternative->attach(
367 'Type' => 'text/plain',
368 'Encoding' => 'quoted-printable',
369 'Charset' => 'UTF-8',
370 #'Encoding' => '7bit',
372 'Disposition' => 'inline',
376 if ( ref($args{'html_body'}) eq 'ARRAY' ) {
377 @html_data = @{ $args{'html_body'} };
379 @html_data = split(/\n/, $args{'html_body'});
382 $alternative->attach(
383 'Type' => 'text/html',
384 'Encoding' => 'quoted-printable',
385 'Data' => [ '<html>',
388 ' '. encode_entities($return{'subject'}),
391 ' <body bgcolor="#ffffff">',
396 'Disposition' => 'inline',
397 #'Filename' => 'invoice.pdf',
400 #no other attachment:
402 # multipart/alternative
406 $return{'content-type'} = 'multipart/related';
407 $return{'mimeparts'} = [ $alternative ];
408 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
409 #$return{'disposition'} = 'inline';
415 =item process_send_email OPTION => VALUE ...
417 Takes arguments as per generate_email() and sends the message. This
418 will die on any error and can be used in the job queue.
422 sub process_send_email {
424 my $error = send_email(generate_email(%message));
425 die "$error\n" if $error;
429 =item process_send_generated_email OPTION => VALUE ...
431 Takes arguments as per send_email() and sends the message. This
432 will die on any error and can be used in the job queue.
436 sub process_send_generated_email {
438 my $error = send_email(%args);
439 die "$error\n" if $error;
443 =item send_fax OPTION => VALUE ...
447 I<dialstring> - (required) 10-digit phone number w/ area code
449 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
453 I<docfile> - (required) Filename of PostScript TIFF Class F document
455 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
464 die 'HylaFAX support has not been configured.'
465 unless $conf->exists('hylafax');
468 require Fax::Hylafax::Client;
472 if ($@ =~ /^Can't locate Fax.*/) {
473 die "You must have Fax::Hylafax::Client installed to use invoice faxing."
479 my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
481 die 'Called send_fax without a \'dialstring\'.'
482 unless exists($options{'dialstring'});
484 if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
485 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
486 my $fh = new File::Temp(
487 TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
490 ) or die "can't open temp file: $!\n";
492 $options{docfile} = $fh->filename;
494 print $fh @{$options{'docdata'}};
497 delete $options{'docdata'};
500 die 'Called send_fax without a \'docfile\' or \'docdata\'.'
501 unless exists($options{'docfile'});
503 #FIXME: Need to send canonical dialstring to HylaFAX, but this only
506 $options{'dialstring'} =~ s/[^\d\+]//g;
507 if ($options{'dialstring'} =~ /^\d{10}$/) {
508 $options{dialstring} = '+1' . $options{'dialstring'};
510 return 'Invalid dialstring ' . $options{'dialstring'} . '.';
513 my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
515 if ($faxjob->success) {
516 warn "Successfully queued fax to '$options{dialstring}' with jobid " .
521 return 'Error while sending FAX: ' . $faxjob->trace;
526 =item states_hash COUNTRY
528 Returns a list of key/value pairs containing state (or other sub-country
529 division) abbriviations and names.
533 use FS::Record qw(qsearch);
534 use Locale::SubCountry;
541 map { s/[\n\r]//g; $_; }
545 'table' => 'cust_main_county',
546 'hashref' => { 'country' => $country },
547 'extra_sql' => 'GROUP BY state',
550 #it could throw a fatal "Invalid country code" error (for example "AX")
551 my $subcountry = eval { new Locale::SubCountry($country) }
552 or return (); # ( '', '(n/a)' );
554 #"i see your schwartz is as big as mine!"
555 map { ( $_->[0] => $_->[1] ) }
556 sort { $a->[1] cmp $b->[1] }
557 map { [ $_ => state_label($_, $subcountry) ] }
561 =item counties STATE COUNTRY
563 Returns a list of counties for this state and country.
568 my( $state, $country ) = @_;
570 map { $_ } #return num_counties($state, $country) unless wantarray;
571 sort map { s/[\n\r]//g; $_; }
574 'select' => 'DISTINCT county',
575 'table' => 'cust_main_county',
576 'hashref' => { 'state' => $state,
577 'country' => $country,
582 =item cities COUNTY STATE COUNTRY
584 Returns a list of cities for this county, state and country.
589 my( $county, $state, $country ) = @_;
591 map { $_ } #return num_cities($county, $state, $country) unless wantarray;
592 sort map { s/[\n\r]//g; $_; }
595 'select' => 'DISTINCT city',
596 'table' => 'cust_main_county',
597 'hashref' => { 'county' => $county,
599 'country' => $country,
604 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
609 my( $state, $country ) = @_;
611 unless ( ref($country) ) {
612 $country = eval { new Locale::SubCountry($country) }
617 # US kludge to avoid changing existing behaviour
618 # also we actually *use* the abbriviations...
619 my $full_name = $country->country_code eq 'US'
621 : $country->full_name($state);
623 $full_name = '' if $full_name eq 'unknown';
624 $full_name =~ s/\(see also.*\)\s*$//;
625 $full_name .= " ($state)" if $full_name;
627 $full_name || $state || '(n/a)';
633 Returns a hash reference of the accepted credit card types. Keys are shorter
634 identifiers and values are the longer strings used by the system (see
635 L<Business::CreditCard>).
642 my $conf = new FS::Conf;
645 #displayname #value (Business::CreditCard)
646 "VISA" => "VISA card",
647 "MasterCard" => "MasterCard",
648 "Discover" => "Discover card",
649 "American Express" => "American Express card",
650 "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
651 "enRoute" => "enRoute",
653 "BankCard" => "BankCard",
654 "Switch" => "Switch",
657 my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
658 if ( @conf_card_types ) {
659 #perhaps the hash is backwards for this, but this way works better for
660 #usage in selfservice
661 %card_types = map { $_ => $card_types{$_} }
664 grep { $card_types{$d} eq $_ } @conf_card_types
674 Returns a hash reference of allowed package billing frequencies.
679 tie my %freq, 'Tie::IxHash', (
680 '0' => '(no recurring fee)',
683 '2d' => 'every two days',
684 '3d' => 'every three days',
686 '2w' => 'biweekly (every 2 weeks)',
688 '45d' => 'every 45 days',
689 '2' => 'bimonthly (every 2 months)',
690 '3' => 'quarterly (every 3 months)',
691 '4' => 'every 4 months',
692 '137d' => 'every 4 1/2 months (137 days)',
693 '6' => 'semiannually (every 6 months)',
695 '13' => 'every 13 months (annually +1 month)',
696 '24' => 'biannually (every 2 years)',
697 '36' => 'triannually (every 3 years)',
698 '48' => '(every 4 years)',
699 '60' => '(every 5 years)',
700 '120' => '(every 10 years)',
705 =item generate_ps FILENAME
707 Returns an postscript rendition of the LaTex file, as a scalar.
708 FILENAME does not contain the .tex suffix and is unlinked by this function.
712 use String::ShellQuote;
717 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
722 my $papersize = $conf->config('papersize') || 'letter';
724 system('dvips', '-q', '-t', $papersize, "$file.dvi", '-o', "$file.ps" ) == 0
725 or die "dvips failed";
727 open(POSTSCRIPT, "<$file.ps")
728 or die "can't open $file.ps: $! (error in LaTeX template?)\n";
730 unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex")
731 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
735 if ( $conf->exists('lpr-postscript_prefix') ) {
736 my $prefix = $conf->config('lpr-postscript_prefix');
737 $ps .= eval qq("$prefix");
740 while (<POSTSCRIPT>) {
746 if ( $conf->exists('lpr-postscript_suffix') ) {
747 my $suffix = $conf->config('lpr-postscript_suffix');
748 $ps .= eval qq("$suffix");
755 =item generate_pdf FILENAME
757 Returns an PDF rendition of the LaTex file, as a scalar. FILENAME does not
758 contain the .tex suffix and is unlinked by this function.
762 use String::ShellQuote;
767 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
770 #system('pdflatex', "$file.tex");
771 #system('pdflatex', "$file.tex");
772 #! LaTeX Error: Unknown graphics extension: .eps.
776 my $sfile = shell_quote $file;
778 #system('dvipdf', "$file.dvi", "$file.pdf" );
779 my $papersize = $conf->config('papersize') || 'letter';
782 "dvips -q -f $sfile.dvi -t $papersize ".
783 "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
786 or die "dvips | gs failed: $!";
788 open(PDF, "<$file.pdf")
789 or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
791 unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex")
792 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
808 #my $sfile = shell_quote $file;
812 '-interaction=batchmode',
813 '\AtBeginDocument{\RequirePackage{pslatex}}',
814 '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
815 '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
820 my $timeout = 30; #? should be more than enough
824 local($SIG{CHLD}) = sub {};
825 run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
826 or warn "bad exit status from pslatex pass $_\n";
830 return if -e "$file.dvi" && -s "$file.dvi";
831 die "pslatex $file.tex failed; see $file.log for details?\n";
835 =item do_print ARRAYREF [, OPTION => VALUE ... ]
837 Sends the lines in ARRAYREF to the printer.
839 Options available are:
845 Uses this agent's 'lpr' configuration setting override instead of the global
850 Uses this command instead of the configured lpr command (overrides both the
851 global value and agentnum).
856 my( $data, %opt ) = @_;
858 my $lpr = ( exists($opt{'lpr'}) && $opt{'lpr'} )
860 : $conf->config('lpr', $opt{'agentnum'} );
863 run3 $lpr, $data, \$outerr, \$outerr;
865 $outerr = ": $outerr" if length($outerr);
866 die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
871 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
873 Converts the filehandle referenced by FILEREF from fixed length record
874 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
875 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs. Each
876 should return the value to be substituted in place of its single argument.
878 Returns false on success or an error if one occurs.
883 my( $fhref, $countref, $lengths, $callbacks) = @_;
885 eval { require Text::CSV_XS; };
889 my $unpacker = new Text::CSV_XS;
891 my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
893 my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
894 my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
897 ) or return "can't open temp file: $!\n"
900 while ( defined(my $line=<$ofh>) ) {
906 return "unexpected input at line $$countref: $line".
907 " -- expected $total but received ". length($line)
908 unless length($line) == $total;
910 $unpacker->combine( map { my $i = $column++;
911 defined( $callbacks->[$i] )
912 ? &{ $callbacks->[$i] }( $_ )
914 } unpack( $template, $line )
916 or return "invalid data for CSV: ". $unpacker->error_input;
918 print $fh $unpacker->string(), "\n"
919 or return "can't write temp file: $!\n";
923 if ( $template ) { close $$fhref; $$fhref = $fh }
929 =item ocr_image IMAGE_SCALAR
931 Runs OCR on the provided image data and returns a list of text lines.
936 my $logo_data = shift;
938 #XXX use conf dir location from Makefile
939 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
940 my $fh = new File::Temp(
941 TEMPLATE => 'bizcard.XXXXXXXX',
942 SUFFIX => '.png', #XXX assuming, but should handle jpg, gif, etc. too
945 ) or die "can't open temp file: $!\n";
947 my $filename = $fh->filename;
949 print $fh $logo_data;
952 run( [qw(ocroscript recognize), $filename], '>'=>"$filename.hocr" )
953 or die "ocroscript recognize failed\n";
955 run( [qw(ocroscript hocr-to-text), "$filename.hocr"], '>pipe'=>\*OUT )
956 or die "ocroscript hocr-to-text failed\n";
958 my @lines = split(/\n/, <OUT> );
960 foreach (@lines) { s/\.c0m\s*$/.com/; }
965 =item bytes_substr STRING, OFFSET[, LENGTH[, REPLACEMENT] ]
967 A replacement for "substr" that counts raw bytes rather than logical
968 characters. Unlike "bytes::substr", will suppress fragmented UTF-8 characters
969 rather than output them. Unlike real "substr", is not an lvalue.
974 my ($string, $offset, $length, $repl) = @_;
976 Encode::encode('utf8', $string),
979 Encode::encode('utf8', $repl)
981 my $chk = $DEBUG ? Encode::FB_WARN : Encode::FB_QUIET;
982 return Encode::decode('utf8', $bytes, $chk);
993 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
995 L<Fax::Hylafax::Client>