communigate provisioning phase 2: add svc_domain.trailer -> communigate TrailerText...
[freeside.git] / FS / FS / Misc.pm
1 package FS::Misc;
2
3 use strict;
4 use vars qw ( @ISA @EXPORT_OK $DEBUG );
5 use Exporter;
6 use Carp;
7 use Data::Dumper;
8 use IPC::Run qw( run timeout );   # for _pslatex
9 use IPC::Run3; # for do_print... should just use IPC::Run i guess
10 use File::Temp;
11 #do NOT depend on any FS:: modules here, causes weird (sometimes unreproducable
12 #until on client machine) dependancy loops.  put them in FS::Misc::Something
13 #instead
14
15 @ISA = qw( Exporter );
16 @EXPORT_OK = qw( generate_email send_email send_fax
17                  states_hash counties cities state_label
18                  card_types
19                  generate_ps generate_pdf do_print
20                  csv_from_fixed
21                );
22
23 $DEBUG = 0;
24
25 =head1 NAME
26
27 FS::Misc - Miscellaneous subroutines
28
29 =head1 SYNOPSIS
30
31   use FS::Misc qw(send_email);
32
33   send_email();
34
35 =head1 DESCRIPTION
36
37 Miscellaneous subroutines.  This module contains miscellaneous subroutines
38 called from multiple other modules.  These are not OO or necessarily related,
39 but are collected here to elimiate code duplication.
40
41 =head1 SUBROUTINES
42
43 =over 4
44
45 =item generate_email OPTION => VALUE ...
46
47 Options:
48
49 =over 4
50
51 =item from
52
53 Sender address, required
54
55 =item to
56
57 Recipient address, required
58
59 =item subject
60
61 email subject, required
62
63 =item html_body
64
65 Email body (HTML alternative).  Arrayref of lines, or scalar.
66
67 Will be placed inside an HTML <BODY> tag.
68
69 =item text_body
70
71 Email body (Text alternative).  Arrayref of lines, or scalar.
72
73 =back
74
75 Returns an argument list to be passsed to L<send_email>.
76
77 =cut
78
79 #false laziness w/FS::cust_bill::generate_email
80
81 use MIME::Entity;
82 use HTML::Entities;
83
84 sub generate_email {
85   my %args = @_;
86
87   my $me = '[FS::Misc::generate_email]';
88
89   my %return = (
90     'from'    => $args{'from'},
91     'to'      => $args{'to'},
92     'subject' => $args{'subject'},
93   );
94
95   #if (ref($args{'to'}) eq 'ARRAY') {
96   #  $return{'to'} = $args{'to'};
97   #} else {
98   #  $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
99   #                         $self->cust_main->invoicing_list
100   #                  ];
101   #}
102
103   warn "$me creating HTML/text multipart message"
104     if $DEBUG;
105
106   $return{'nobody'} = 1;
107
108   my $alternative = build MIME::Entity
109     'Type'        => 'multipart/alternative',
110     'Encoding'    => '7bit',
111     'Disposition' => 'inline'
112   ;
113
114   my $data;
115   if ( ref($args{'text_body'}) eq 'ARRAY' ) {
116     $data = $args{'text_body'};
117   } else {
118     $data = [ split(/\n/, $args{'text_body'}) ];
119   }
120
121   $alternative->attach(
122     'Type'        => 'text/plain',
123     #'Encoding'    => 'quoted-printable',
124     'Encoding'    => '7bit',
125     'Data'        => $data,
126     'Disposition' => 'inline',
127   );
128
129   my @html_data;
130   if ( ref($args{'html_body'}) eq 'ARRAY' ) {
131     @html_data = @{ $args{'html_body'} };
132   } else {
133     @html_data = split(/\n/, $args{'html_body'});
134   }
135
136   $alternative->attach(
137     'Type'        => 'text/html',
138     'Encoding'    => 'quoted-printable',
139     'Data'        => [ '<html>',
140                        '  <head>',
141                        '    <title>',
142                        '      '. encode_entities($return{'subject'}), 
143                        '    </title>',
144                        '  </head>',
145                        '  <body bgcolor="#e8e8e8">',
146                        @html_data,
147                        '  </body>',
148                        '</html>',
149                      ],
150     'Disposition' => 'inline',
151     #'Filename'    => 'invoice.pdf',
152   );
153
154   #no other attachment:
155   # multipart/related
156   #   multipart/alternative
157   #     text/plain
158   #     text/html
159
160   $return{'content-type'} = 'multipart/related';
161   $return{'mimeparts'} = [ $alternative ];
162   $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
163   #$return{'disposition'} = 'inline';
164
165   %return;
166
167 }
168
169 =item send_email OPTION => VALUE ...
170
171 Options:
172
173 =over 4
174
175 =item from
176
177 (required)
178
179 =item to
180
181 (required) comma-separated scalar or arrayref of recipients
182
183 =item subject
184
185 (required)
186
187 =item content-type
188
189 (optional) MIME type for the body
190
191 =item body
192
193 (required unless I<nobody> is true) arrayref of body text lines
194
195 =item mimeparts
196
197 (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().
198
199 =item nobody
200
201 (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,
202 I<content-type>, if specified, overrides the default "multipart/mixed" for the outermost MIME container.
203
204 =item content-encoding
205
206 (optional) when using nobody, optional top-level MIME
207 encoding which, if specified, overrides the default "7bit".
208
209 =item type
210
211 (optional) type parameter for multipart/related messages
212
213 =back
214
215 =cut
216
217 use vars qw( $conf );
218 use Date::Format;
219 use MIME::Entity;
220 use Email::Sender::Simple qw(sendmail);
221 use Email::Sender::Transport::SMTP;
222 use Email::Sender::Transport::SMTP::TLS;
223 use FS::UID;
224
225 FS::UID->install_callback( sub {
226   $conf = new FS::Conf;
227 } );
228
229 sub send_email {
230   my(%options) = @_;
231   if ( $DEBUG ) {
232     my %doptions = %options;
233     $doptions{'body'} = '(full body not shown in debug)';
234     warn "FS::Misc::send_email called with options:\n  ". Dumper(\%doptions);
235 #         join("\n", map { "  $_: ". $options{$_} } keys %options ). "\n"
236   }
237
238   my $to = ref($options{to}) ? join(', ', @{ $options{to} } ) : $options{to};
239
240   my @mimeargs = ();
241   my @mimeparts = ();
242   if ( $options{'nobody'} ) {
243
244     croak "'mimeparts' option required when 'nobody' option given\n"
245       unless $options{'mimeparts'};
246
247     @mimeparts = @{$options{'mimeparts'}};
248
249     @mimeargs = (
250       'Type'         => ( $options{'content-type'} || 'multipart/mixed' ),
251       'Encoding'     => ( $options{'content-encoding'} || '7bit' ),
252     );
253
254   } else {
255
256     @mimeparts = @{$options{'mimeparts'}}
257       if ref($options{'mimeparts'}) eq 'ARRAY';
258
259     if (scalar(@mimeparts)) {
260
261       @mimeargs = (
262         'Type'     => 'multipart/mixed',
263         'Encoding' => '7bit',
264       );
265   
266       unshift @mimeparts, { 
267         'Type'        => ( $options{'content-type'} || 'text/plain' ),
268         'Data'        => $options{'body'},
269         'Encoding'    => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
270         'Disposition' => 'inline',
271       };
272
273     } else {
274     
275       @mimeargs = (
276         'Type'     => ( $options{'content-type'} || 'text/plain' ),
277         'Data'     => $options{'body'},
278         'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
279       );
280
281     }
282
283   }
284
285   my $domain;
286   if ( $options{'from'} =~ /\@([\w\.\-]+)/ ) {
287     $domain = $1;
288   } else {
289     warn 'no domain found in invoice from address '. $options{'from'}.
290          '; constructing Message-ID (and saying HELO) @example.com'; 
291     $domain = 'example.com';
292   }
293   my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
294
295   my $message = MIME::Entity->build(
296     'From'       => $options{'from'},
297     'To'         => $to,
298     'Sender'     => $options{'from'},
299     'Reply-To'   => $options{'from'},
300     'Date'       => time2str("%a, %d %b %Y %X %z", time),
301     'Subject'    => $options{'subject'},
302     'Message-ID' => "<$message_id>",
303     @mimeargs,
304   );
305
306   if ( $options{'type'} ) {
307     #false laziness w/cust_bill::generate_email
308     $message->head->replace('Content-type',
309       $message->mime_type.
310       '; boundary="'. $message->head->multipart_boundary. '"'.
311       '; type='. $options{'type'}
312     );
313   }
314
315   foreach my $part (@mimeparts) {
316
317     if ( UNIVERSAL::isa($part, 'MIME::Entity') ) {
318
319       warn "attaching MIME part from MIME::Entity object\n"
320         if $DEBUG;
321       $message->add_part($part);
322
323     } elsif ( ref($part) eq 'HASH' ) {
324
325       warn "attaching MIME part from hashref:\n".
326            join("\n", map "  $_: ".$part->{$_}, keys %$part ). "\n"
327         if $DEBUG;
328       $message->attach(%$part);
329
330     } else {
331       croak "mimepart $part isn't a hashref or MIME::Entity object!";
332     }
333
334   }
335
336   #send the email
337
338   my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
339                    'helo' => $domain,
340                  );
341
342   my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
343   $smtp_opt{'port'} = $port;
344
345   my $transport;
346   if ( defined($enc) && $enc eq 'starttls' ) {
347     $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
348     $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
349   } else {
350     if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
351       $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
352     }
353     $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
354     $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
355   }
356
357   eval { sendmail($message, { transport => $transport }); };
358   ref($@) ? ( $@->code ? $@->code.' ' : '' ). $@->message
359           : $@;
360
361 }
362
363 =item send_fax OPTION => VALUE ...
364
365 Options:
366
367 I<dialstring> - (required) 10-digit phone number w/ area code
368
369 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
370
371 -or-
372
373 I<docfile> - (required) Filename of PostScript TIFF Class F document
374
375 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
376
377
378 =cut
379
380 sub send_fax {
381
382   my %options = @_;
383
384   die 'HylaFAX support has not been configured.'
385     unless $conf->exists('hylafax');
386
387   eval {
388     require Fax::Hylafax::Client;
389   };
390
391   if ($@) {
392     if ($@ =~ /^Can't locate Fax.*/) {
393       die "You must have Fax::Hylafax::Client installed to use invoice faxing."
394     } else {
395       die $@;
396     }
397   }
398
399   my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
400
401   die 'Called send_fax without a \'dialstring\'.'
402     unless exists($options{'dialstring'});
403
404   if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
405       my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
406       my $fh = new File::Temp(
407         TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
408         DIR      => $dir,
409         UNLINK   => 0,
410       ) or die "can't open temp file: $!\n";
411
412       $options{docfile} = $fh->filename;
413
414       print $fh @{$options{'docdata'}};
415       close $fh;
416
417       delete $options{'docdata'};
418   }
419
420   die 'Called send_fax without a \'docfile\' or \'docdata\'.'
421     unless exists($options{'docfile'});
422
423   #FIXME: Need to send canonical dialstring to HylaFAX, but this only
424   #       works in the US.
425
426   $options{'dialstring'} =~ s/[^\d\+]//g;
427   if ($options{'dialstring'} =~ /^\d{10}$/) {
428     $options{dialstring} = '+1' . $options{'dialstring'};
429   } else {
430     return 'Invalid dialstring ' . $options{'dialstring'} . '.';
431   }
432
433   my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
434
435   if ($faxjob->success) {
436     warn "Successfully queued fax to '$options{dialstring}' with jobid " .
437            $faxjob->jobid
438       if $DEBUG;
439     return '';
440   } else {
441     return 'Error while sending FAX: ' . $faxjob->trace;
442   }
443
444 }
445
446 =item states_hash COUNTRY
447
448 Returns a list of key/value pairs containing state (or other sub-country
449 division) abbriviations and names.
450
451 =cut
452
453 use FS::Record qw(qsearch);
454 use Locale::SubCountry;
455
456 sub states_hash {
457   my($country) = @_;
458
459   my @states = 
460 #     sort
461      map { s/[\n\r]//g; $_; }
462      map { $_->state; }
463          qsearch({ 
464                    'select'    => 'state',
465                    'table'     => 'cust_main_county',
466                    'hashref'   => { 'country' => $country },
467                    'extra_sql' => 'GROUP BY state',
468                 });
469
470   #it could throw a fatal "Invalid country code" error (for example "AX")
471   my $subcountry = eval { new Locale::SubCountry($country) }
472     or return ( '', '(n/a)' );
473
474   #"i see your schwartz is as big as mine!"
475   map  { ( $_->[0] => $_->[1] ) }
476   sort { $a->[1] cmp $b->[1] }
477   map  { [ $_ => state_label($_, $subcountry) ] }
478        @states;
479 }
480
481 =item counties STATE COUNTRY
482
483 Returns a list of counties for this state and country.
484
485 =cut
486
487 sub counties {
488   my( $state, $country ) = @_;
489
490   map { $_ } #return num_counties($state, $country) unless wantarray;
491   sort map { s/[\n\r]//g; $_; }
492        map { $_->county }
493            qsearch({
494              'select'  => 'DISTINCT county',
495              'table'   => 'cust_main_county',
496              'hashref' => { 'state'   => $state,
497                             'country' => $country,
498                           },
499            });
500 }
501
502 =item cities COUNTY STATE COUNTRY
503
504 Returns a list of cities for this county, state and country.
505
506 =cut
507
508 sub cities {
509   my( $county, $state, $country ) = @_;
510
511   map { $_ } #return num_cities($county, $state, $country) unless wantarray;
512   sort map { s/[\n\r]//g; $_; }
513        map { $_->city }
514            qsearch({
515              'select'  => 'DISTINCT city',
516              'table'   => 'cust_main_county',
517              'hashref' => { 'county'  => $county,
518                             'state'   => $state,
519                             'country' => $country,
520                           },
521            });
522 }
523
524 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
525
526 =cut
527
528 sub state_label {
529   my( $state, $country ) = @_;
530
531   unless ( ref($country) ) {
532     $country = eval { new Locale::SubCountry($country) }
533       or return'(n/a)';
534
535   }
536
537   # US kludge to avoid changing existing behaviour 
538   # also we actually *use* the abbriviations...
539   my $full_name = $country->country_code eq 'US'
540                     ? ''
541                     : $country->full_name($state);
542
543   $full_name = '' if $full_name eq 'unknown';
544   $full_name =~ s/\(see also.*\)\s*$//;
545   $full_name .= " ($state)" if $full_name;
546
547   $full_name || $state || '(n/a)';
548
549 }
550
551 =item card_types
552
553 Returns a hash reference of the accepted credit card types.  Keys are shorter
554 identifiers and values are the longer strings used by the system (see
555 L<Business::CreditCard>).
556
557 =cut
558
559 #$conf from above
560
561 sub card_types {
562   my $conf = new FS::Conf;
563
564   my %card_types = (
565     #displayname                    #value (Business::CreditCard)
566     "VISA"                       => "VISA card",
567     "MasterCard"                 => "MasterCard",
568     "Discover"                   => "Discover card",
569     "American Express"           => "American Express card",
570     "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
571     "enRoute"                    => "enRoute",
572     "JCB"                        => "JCB",
573     "BankCard"                   => "BankCard",
574     "Switch"                     => "Switch",
575     "Solo"                       => "Solo",
576   );
577   my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
578   if ( @conf_card_types ) {
579     #perhaps the hash is backwards for this, but this way works better for
580     #usage in selfservice
581     %card_types = map  { $_ => $card_types{$_} }
582                   grep {
583                          my $d = $_;
584                            grep { $card_types{$d} eq $_ } @conf_card_types
585                        }
586                     keys %card_types;
587   }
588
589   \%card_types;
590 }
591
592 =item generate_ps FILENAME
593
594 Returns an postscript rendition of the LaTex file, as a scalar.
595 FILENAME does not contain the .tex suffix and is unlinked by this function.
596
597 =cut
598
599 use String::ShellQuote;
600
601 sub generate_ps {
602   my $file = shift;
603
604   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
605   chdir($dir);
606
607   _pslatex($file);
608
609   system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
610     or die "dvips failed";
611
612   open(POSTSCRIPT, "<$file.ps")
613     or die "can't open $file.ps: $! (error in LaTeX template?)\n";
614
615   unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
616
617   my $ps = '';
618
619   if ( $conf->exists('lpr-postscript_prefix') ) {
620     my $prefix = $conf->config('lpr-postscript_prefix');
621     $ps .= eval qq("$prefix");
622   }
623
624   while (<POSTSCRIPT>) {
625     $ps .= $_;
626   }
627
628   close POSTSCRIPT;
629
630   if ( $conf->exists('lpr-postscript_suffix') ) {
631     my $suffix = $conf->config('lpr-postscript_suffix');
632     $ps .= eval qq("$suffix");
633   }
634
635   return $ps;
636
637 }
638
639 =item generate_pdf FILENAME
640
641 Returns an PDF rendition of the LaTex file, as a scalar.  FILENAME does not
642 contain the .tex suffix and is unlinked by this function.
643
644 =cut
645
646 use String::ShellQuote;
647
648 sub generate_pdf {
649   my $file = shift;
650
651   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
652   chdir($dir);
653
654   #system('pdflatex', "$file.tex");
655   #system('pdflatex', "$file.tex");
656   #! LaTeX Error: Unknown graphics extension: .eps.
657
658   _pslatex($file);
659
660   my $sfile = shell_quote $file;
661
662   #system('dvipdf', "$file.dvi", "$file.pdf" );
663   system(
664     "dvips -q -t letter -f $sfile.dvi ".
665     "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
666     "     -c save pop -"
667   ) == 0
668     or die "dvips | gs failed: $!";
669
670   open(PDF, "<$file.pdf")
671     or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
672
673   unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
674
675   my $pdf = '';
676   while (<PDF>) {
677     $pdf .= $_;
678   }
679
680   close PDF;
681
682   return $pdf;
683
684 }
685
686 sub _pslatex {
687   my $file = shift;
688
689   #my $sfile = shell_quote $file;
690
691   my @cmd = (
692     'latex',
693     '-interaction=batchmode',
694     '\AtBeginDocument{\RequirePackage{pslatex}}',
695     '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
696     '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
697     '\PSLATEXTMP',
698     "$file.tex"
699   );
700
701   my $timeout = 30; #? should be more than enough
702
703   for ( 1, 2 ) {
704
705     local($SIG{CHLD}) = sub {};
706     run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
707       or die "pslatex $file.tex failed; see $file.log for details?\n";
708
709   }
710
711 }
712
713 =item print ARRAYREF
714
715 Sends the lines in ARRAYREF to the printer.
716
717 =cut
718
719 sub do_print {
720   my $data = shift;
721
722   my $lpr = $conf->config('lpr');
723
724   my $outerr = '';
725   run3 $lpr, $data, \$outerr, \$outerr;
726   if ( $? ) {
727     $outerr = ": $outerr" if length($outerr);
728     die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
729   }
730
731 }
732
733 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
734
735 Converts the filehandle referenced by FILEREF from fixed length record
736 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
737 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs.  Each
738 should return the value to be substituted in place of its single argument.
739
740 Returns false on success or an error if one occurs.
741
742 =cut
743
744 sub csv_from_fixed {
745   my( $fhref, $countref, $lengths, $callbacks) = @_;
746
747   eval { require Text::CSV_XS; };
748   return $@ if $@;
749
750   my $ofh = $$fhref;
751   my $unpacker = new Text::CSV_XS;
752   my $total = 0;
753   my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
754
755   my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
756   my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
757                            DIR      => $dir,
758                            UNLINK   => 0,
759                          ) or return "can't open temp file: $!\n"
760     if $template;
761
762   while ( defined(my $line=<$ofh>) ) {
763     $$countref++;
764     if ( $template ) {
765       my $column = 0;
766
767       chomp $line;
768       return "unexpected input at line $$countref: $line".
769              " -- expected $total but received ". length($line)
770         unless length($line) == $total;
771
772       $unpacker->combine( map { my $i = $column++;
773                                 defined( $callbacks->[$i] )
774                                   ? &{ $callbacks->[$i] }( $_ )
775                                   : $_
776                               } unpack( $template, $line )
777                         )
778         or return "invalid data for CSV: ". $unpacker->error_input;
779
780       print $fh $unpacker->string(), "\n"
781         or return "can't write temp file: $!\n";
782     }
783   }
784
785   if ( $template ) { close $$fhref; $$fhref = $fh }
786
787   seek $$fhref, 0, 0;
788   '';
789 }
790
791
792 =back
793
794 =head1 BUGS
795
796 This package exists.
797
798 =head1 SEE ALSO
799
800 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
801
802 L<Fax::Hylafax::Client>
803
804 =cut
805
806 1;