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( generate_email send_email send_fax
18 states_hash counties state_label
21 generate_ps generate_pdf do_print
29 FS::Misc - Miscellaneous subroutines
33 use FS::Misc qw(send_email);
39 Miscellaneous subroutines. This module contains miscellaneous subroutines
40 called from multiple other modules. These are not OO or necessarily related,
41 but are collected here to elimiate code duplication.
47 =item generate_email OPTION => VALUE ...
55 Sender address, required
59 Recipient address, required
63 Blind copy address, optional
67 email subject, required
71 Email body (HTML alternative). Arrayref of lines, or scalar.
73 Will be placed inside an HTML <BODY> tag.
77 Email body (Text alternative). Arrayref of lines, or scalar.
81 Returns an argument list to be passsed to L<send_email>.
85 #false laziness w/FS::cust_bill::generate_email
93 my $me = '[FS::Misc::generate_email]';
96 'from' => $args{'from'},
98 'bcc' => $args{'bcc'},
99 'subject' => $args{'subject'},
102 #if (ref($args{'to'}) eq 'ARRAY') {
103 # $return{'to'} = $args{'to'};
105 # $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
106 # $self->cust_main->invoicing_list
110 warn "$me creating HTML/text multipart message"
113 $return{'nobody'} = 1;
115 my $alternative = build MIME::Entity
116 'Type' => 'multipart/alternative',
117 'Encoding' => '7bit',
118 'Disposition' => 'inline'
122 if ( ref($args{'text_body'}) eq 'ARRAY' ) {
123 $data = $args{'text_body'};
125 $data = [ split(/\n/, $args{'text_body'}) ];
128 $alternative->attach(
129 'Type' => 'text/plain',
130 #'Encoding' => 'quoted-printable',
131 'Encoding' => '7bit',
133 'Disposition' => 'inline',
137 if ( ref($args{'html_body'}) eq 'ARRAY' ) {
138 @html_data = @{ $args{'html_body'} };
140 @html_data = split(/\n/, $args{'html_body'});
143 $alternative->attach(
144 'Type' => 'text/html',
145 'Encoding' => 'quoted-printable',
146 'Data' => [ '<html>',
149 ' '. encode_entities($return{'subject'}),
152 ' <body bgcolor="#e8e8e8">',
157 'Disposition' => 'inline',
158 #'Filename' => 'invoice.pdf',
161 #no other attachment:
163 # multipart/alternative
167 $return{'content-type'} = 'multipart/related';
168 $return{'mimeparts'} = [ $alternative ];
169 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
170 #$return{'disposition'} = 'inline';
176 =item send_email OPTION => VALUE ...
188 (required) comma-separated scalar or arrayref of recipients
196 (optional) MIME type for the body
200 (required unless I<nobody> is true) arrayref of body text lines
204 (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().
208 (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,
209 I<content-type>, if specified, overrides the default "multipart/mixed" for the outermost MIME container.
211 =item content-encoding
213 (optional) when using nobody, optional top-level MIME
214 encoding which, if specified, overrides the default "7bit".
218 (optional) type parameter for multipart/related messages
224 use vars qw( $conf );
227 use Email::Sender::Simple qw(sendmail);
228 use Email::Sender::Transport::SMTP;
229 use Email::Sender::Transport::SMTP::TLS;
232 FS::UID->install_callback( sub {
233 $conf = new FS::Conf;
239 my %doptions = %options;
240 $doptions{'body'} = '(full body not shown in debug)';
241 warn "FS::Misc::send_email called with options:\n ". Dumper(\%doptions);
242 # join("\n", map { " $_: ". $options{$_} } keys %options ). "\n"
245 my @to = ref($options{to}) ? @{ $options{to} } : ( $options{to} );
249 if ( $options{'nobody'} ) {
251 croak "'mimeparts' option required when 'nobody' option given\n"
252 unless $options{'mimeparts'};
254 @mimeparts = @{$options{'mimeparts'}};
257 'Type' => ( $options{'content-type'} || 'multipart/mixed' ),
258 'Encoding' => ( $options{'content-encoding'} || '7bit' ),
263 @mimeparts = @{$options{'mimeparts'}}
264 if ref($options{'mimeparts'}) eq 'ARRAY';
266 if (scalar(@mimeparts)) {
269 'Type' => 'multipart/mixed',
270 'Encoding' => '7bit',
273 unshift @mimeparts, {
274 'Type' => ( $options{'content-type'} || 'text/plain' ),
275 'Data' => $options{'body'},
276 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
277 'Disposition' => 'inline',
283 'Type' => ( $options{'content-type'} || 'text/plain' ),
284 'Data' => $options{'body'},
285 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
293 if ( $options{'from'} =~ /\@([\w\.\-]+)/ ) {
296 warn 'no domain found in invoice from address '. $options{'from'}.
297 '; constructing Message-ID (and saying HELO) @example.com';
298 $domain = 'example.com';
300 my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
302 my $message = MIME::Entity->build(
303 'From' => $options{'from'},
304 'To' => join(', ', @to),
305 'Sender' => $options{'from'},
306 'Reply-To' => $options{'from'},
307 'Date' => time2str("%a, %d %b %Y %X %z", time),
308 'Subject' => $options{'subject'},
309 'Message-ID' => "<$message_id>",
313 if ( $options{'type'} ) {
314 #false laziness w/cust_bill::generate_email
315 $message->head->replace('Content-type',
317 '; boundary="'. $message->head->multipart_boundary. '"'.
318 '; type='. $options{'type'}
322 foreach my $part (@mimeparts) {
324 if ( UNIVERSAL::isa($part, 'MIME::Entity') ) {
326 warn "attaching MIME part from MIME::Entity object\n"
328 $message->add_part($part);
330 } elsif ( ref($part) eq 'HASH' ) {
332 warn "attaching MIME part from hashref:\n".
333 join("\n", map " $_: ".$part->{$_}, keys %$part ). "\n"
335 $message->attach(%$part);
338 croak "mimepart $part isn't a hashref or MIME::Entity object!";
345 my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
349 my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
350 $smtp_opt{'port'} = $port;
353 if ( defined($enc) && $enc eq 'starttls' ) {
354 $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
355 $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
357 if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
358 $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
360 $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
361 $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
364 push @to, $options{bcc} if defined($options{bcc});
365 local $@; # just in case
366 eval { sendmail($message, { transport => $transport,
367 from => $options{from},
370 if(ref($@) and $@->isa('Email::Sender::Failure')) {
371 return ($@->code ? $@->code.' ' : '').$@->message
378 =item send_fax OPTION => VALUE ...
382 I<dialstring> - (required) 10-digit phone number w/ area code
384 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
388 I<docfile> - (required) Filename of PostScript TIFF Class F document
390 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
399 die 'HylaFAX support has not been configured.'
400 unless $conf->exists('hylafax');
403 require Fax::Hylafax::Client;
407 if ($@ =~ /^Can't locate Fax.*/) {
408 die "You must have Fax::Hylafax::Client installed to use invoice faxing."
414 my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
416 die 'Called send_fax without a \'dialstring\'.'
417 unless exists($options{'dialstring'});
419 if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
420 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
421 my $fh = new File::Temp(
422 TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
425 ) or die "can't open temp file: $!\n";
427 $options{docfile} = $fh->filename;
429 print $fh @{$options{'docdata'}};
432 delete $options{'docdata'};
435 die 'Called send_fax without a \'docfile\' or \'docdata\'.'
436 unless exists($options{'docfile'});
438 #FIXME: Need to send canonical dialstring to HylaFAX, but this only
441 $options{'dialstring'} =~ s/[^\d\+]//g;
442 if ($options{'dialstring'} =~ /^\d{10}$/) {
443 $options{dialstring} = '+1' . $options{'dialstring'};
445 return 'Invalid dialstring ' . $options{'dialstring'} . '.';
448 my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
450 if ($faxjob->success) {
451 warn "Successfully queued fax to '$options{dialstring}' with jobid " .
456 return 'Error while sending FAX: ' . $faxjob->trace;
461 =item states_hash COUNTRY
463 Returns a list of key/value pairs containing state (or other sub-country
464 division) abbriviations and names.
468 use FS::Record qw(qsearch);
469 use Locale::SubCountry;
476 map { s/[\n\r]//g; $_; }
480 'table' => 'cust_main_county',
481 'hashref' => { 'country' => $country },
482 'extra_sql' => 'GROUP BY state',
485 #it could throw a fatal "Invalid country code" error (for example "AX")
486 my $subcountry = eval { new Locale::SubCountry($country) }
487 or return ( '', '(n/a)' );
489 #"i see your schwartz is as big as mine!"
490 map { ( $_->[0] => $_->[1] ) }
491 sort { $a->[1] cmp $b->[1] }
492 map { [ $_ => state_label($_, $subcountry) ] }
496 =item counties STATE COUNTRY
498 Returns a list of counties for this state and country.
503 my( $state, $country ) = @_;
505 sort map { s/[\n\r]//g; $_; }
508 'select' => 'DISTINCT county',
509 'table' => 'cust_main_county',
510 'hashref' => { 'state' => $state,
511 'country' => $country,
516 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
521 my( $state, $country ) = @_;
523 unless ( ref($country) ) {
524 $country = eval { new Locale::SubCountry($country) }
529 # US kludge to avoid changing existing behaviour
530 # also we actually *use* the abbriviations...
531 my $full_name = $country->country_code eq 'US'
533 : $country->full_name($state);
535 $full_name = '' if $full_name eq 'unknown';
536 $full_name =~ s/\(see also.*\)\s*$//;
537 $full_name .= " ($state)" if $full_name;
539 $full_name || $state || '(n/a)';
545 Returns a hash reference of the accepted credit card types. Keys are shorter
546 identifiers and values are the longer strings used by the system (see
547 L<Business::CreditCard>).
554 my $conf = new FS::Conf;
557 #displayname #value (Business::CreditCard)
558 "VISA" => "VISA card",
559 "MasterCard" => "MasterCard",
560 "Discover" => "Discover card",
561 "American Express" => "American Express card",
562 "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
563 "enRoute" => "enRoute",
565 "BankCard" => "BankCard",
566 "Switch" => "Switch",
569 my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
570 if ( @conf_card_types ) {
571 #perhaps the hash is backwards for this, but this way works better for
572 #usage in selfservice
573 %card_types = map { $_ => $card_types{$_} }
576 grep { $card_types{$d} eq $_ } @conf_card_types
584 =item generate_ps FILENAME
586 Returns an postscript rendition of the LaTex file, as a scalar.
587 FILENAME does not contain the .tex suffix and is unlinked by this function.
591 use String::ShellQuote;
596 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
601 system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
602 or die "dvips failed";
604 open(POSTSCRIPT, "<$file.ps")
605 or die "can't open $file.ps: $! (error in LaTeX template?)\n";
607 unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
611 if ( $conf->exists('lpr-postscript_prefix') ) {
612 my $prefix = $conf->config('lpr-postscript_prefix');
613 $ps .= eval qq("$prefix");
616 while (<POSTSCRIPT>) {
622 if ( $conf->exists('lpr-postscript_suffix') ) {
623 my $suffix = $conf->config('lpr-postscript_suffix');
624 $ps .= eval qq("$suffix");
633 Returns a hash reference of allowed package billing frequencies.
638 tie my %freq, 'Tie::IxHash', (
639 '0' => '(no recurring fee)',
642 '2d' => 'every two days',
643 '3d' => 'every three days',
645 '2w' => 'biweekly (every 2 weeks)',
647 '45d' => 'every 45 days',
648 '2' => 'bimonthly (every 2 months)',
649 '3' => 'quarterly (every 3 months)',
650 '4' => 'every 4 months',
651 '137d' => 'every 4 1/2 months (137 days)',
652 '6' => 'semiannually (every 6 months)',
654 '13' => 'every 13 months (annually +1 month)',
655 '24' => 'biannually (every 2 years)',
656 '36' => 'triannually (every 3 years)',
657 '48' => '(every 4 years)',
658 '60' => '(every 5 years)',
659 '120' => '(every 10 years)',
664 =item generate_pdf FILENAME
666 Returns an PDF rendition of the LaTex file, as a scalar. FILENAME does not
667 contain the .tex suffix and is unlinked by this function.
671 use String::ShellQuote;
676 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
679 #system('pdflatex', "$file.tex");
680 #system('pdflatex', "$file.tex");
681 #! LaTeX Error: Unknown graphics extension: .eps.
685 my $sfile = shell_quote $file;
687 #system('dvipdf', "$file.dvi", "$file.pdf" );
689 "dvips -q -t letter -f $sfile.dvi ".
690 "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
693 or die "dvips | gs failed: $!";
695 open(PDF, "<$file.pdf")
696 or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
698 unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
714 #my $sfile = shell_quote $file;
718 '-interaction=batchmode',
719 '\AtBeginDocument{\RequirePackage{pslatex}}',
720 '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
721 '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
726 my $timeout = 30; #? should be more than enough
730 local($SIG{CHLD}) = sub {};
731 run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
732 or die "pslatex $file.tex failed; see $file.log for details?\n";
740 Sends the lines in ARRAYREF to the printer.
747 my $lpr = $conf->config('lpr');
750 run3 $lpr, $data, \$outerr, \$outerr;
752 $outerr = ": $outerr" if length($outerr);
753 die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
758 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
760 Converts the filehandle referenced by FILEREF from fixed length record
761 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
762 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs. Each
763 should return the value to be substituted in place of its single argument.
765 Returns false on success or an error if one occurs.
770 my( $fhref, $countref, $lengths, $callbacks) = @_;
772 eval { require Text::CSV_XS; };
776 my $unpacker = new Text::CSV_XS;
778 my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
780 my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
781 my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
784 ) or return "can't open temp file: $!\n"
787 while ( defined(my $line=<$ofh>) ) {
793 return "unexpected input at line $$countref: $line".
794 " -- expected $total but received ". length($line)
795 unless length($line) == $total;
797 $unpacker->combine( map { my $i = $column++;
798 defined( $callbacks->[$i] )
799 ? &{ $callbacks->[$i] }( $_ )
801 } unpack( $template, $line )
803 or return "invalid data for CSV: ". $unpacker->error_input;
805 print $fh $unpacker->string(), "\n"
806 or return "can't write temp file: $!\n";
810 if ( $template ) { close $$fhref; $$fhref = $fh }
825 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
827 L<Fax::Hylafax::Client>