afe6c633b17703537323f194d9ad637eeba22d51
[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 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($@) eq 'Email::Sender::Failure'
359     ? ( $@->code ? $@->code.' ' : '' ). $@->message
360     : $@;
361
362 }
363
364 =item send_fax OPTION => VALUE ...
365
366 Options:
367
368 I<dialstring> - (required) 10-digit phone number w/ area code
369
370 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
371
372 -or-
373
374 I<docfile> - (required) Filename of PostScript TIFF Class F document
375
376 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
377
378
379 =cut
380
381 sub send_fax {
382
383   my %options = @_;
384
385   die 'HylaFAX support has not been configured.'
386     unless $conf->exists('hylafax');
387
388   eval {
389     require Fax::Hylafax::Client;
390   };
391
392   if ($@) {
393     if ($@ =~ /^Can't locate Fax.*/) {
394       die "You must have Fax::Hylafax::Client installed to use invoice faxing."
395     } else {
396       die $@;
397     }
398   }
399
400   my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
401
402   die 'Called send_fax without a \'dialstring\'.'
403     unless exists($options{'dialstring'});
404
405   if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
406       my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
407       my $fh = new File::Temp(
408         TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
409         DIR      => $dir,
410         UNLINK   => 0,
411       ) or die "can't open temp file: $!\n";
412
413       $options{docfile} = $fh->filename;
414
415       print $fh @{$options{'docdata'}};
416       close $fh;
417
418       delete $options{'docdata'};
419   }
420
421   die 'Called send_fax without a \'docfile\' or \'docdata\'.'
422     unless exists($options{'docfile'});
423
424   #FIXME: Need to send canonical dialstring to HylaFAX, but this only
425   #       works in the US.
426
427   $options{'dialstring'} =~ s/[^\d\+]//g;
428   if ($options{'dialstring'} =~ /^\d{10}$/) {
429     $options{dialstring} = '+1' . $options{'dialstring'};
430   } else {
431     return 'Invalid dialstring ' . $options{'dialstring'} . '.';
432   }
433
434   my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
435
436   if ($faxjob->success) {
437     warn "Successfully queued fax to '$options{dialstring}' with jobid " .
438            $faxjob->jobid
439       if $DEBUG;
440     return '';
441   } else {
442     return 'Error while sending FAX: ' . $faxjob->trace;
443   }
444
445 }
446
447 =item states_hash COUNTRY
448
449 Returns a list of key/value pairs containing state (or other sub-country
450 division) abbriviations and names.
451
452 =cut
453
454 use FS::Record qw(qsearch);
455 use Locale::SubCountry;
456
457 sub states_hash {
458   my($country) = @_;
459
460   my @states = 
461 #     sort
462      map { s/[\n\r]//g; $_; }
463      map { $_->state; }
464          qsearch({ 
465                    'select'    => 'state',
466                    'table'     => 'cust_main_county',
467                    'hashref'   => { 'country' => $country },
468                    'extra_sql' => 'GROUP BY state',
469                 });
470
471   #it could throw a fatal "Invalid country code" error (for example "AX")
472   my $subcountry = eval { new Locale::SubCountry($country) }
473     or return ( '', '(n/a)' );
474
475   #"i see your schwartz is as big as mine!"
476   map  { ( $_->[0] => $_->[1] ) }
477   sort { $a->[1] cmp $b->[1] }
478   map  { [ $_ => state_label($_, $subcountry) ] }
479        @states;
480 }
481
482 =item counties STATE COUNTRY
483
484 Returns a list of counties for this state and country.
485
486 =cut
487
488 sub counties {
489   my( $state, $country ) = @_;
490
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 state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
503
504 =cut
505
506 sub state_label {
507   my( $state, $country ) = @_;
508
509   unless ( ref($country) ) {
510     $country = eval { new Locale::SubCountry($country) }
511       or return'(n/a)';
512
513   }
514
515   # US kludge to avoid changing existing behaviour 
516   # also we actually *use* the abbriviations...
517   my $full_name = $country->country_code eq 'US'
518                     ? ''
519                     : $country->full_name($state);
520
521   $full_name = '' if $full_name eq 'unknown';
522   $full_name =~ s/\(see also.*\)\s*$//;
523   $full_name .= " ($state)" if $full_name;
524
525   $full_name || $state || '(n/a)';
526
527 }
528
529 =item card_types
530
531 Returns a hash reference of the accepted credit card types.  Keys are shorter
532 identifiers and values are the longer strings used by the system (see
533 L<Business::CreditCard>).
534
535 =cut
536
537 #$conf from above
538
539 sub card_types {
540   my $conf = new FS::Conf;
541
542   my %card_types = (
543     #displayname                    #value (Business::CreditCard)
544     "VISA"                       => "VISA card",
545     "MasterCard"                 => "MasterCard",
546     "Discover"                   => "Discover card",
547     "American Express"           => "American Express card",
548     "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
549     "enRoute"                    => "enRoute",
550     "JCB"                        => "JCB",
551     "BankCard"                   => "BankCard",
552     "Switch"                     => "Switch",
553     "Solo"                       => "Solo",
554   );
555   my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
556   if ( @conf_card_types ) {
557     #perhaps the hash is backwards for this, but this way works better for
558     #usage in selfservice
559     %card_types = map  { $_ => $card_types{$_} }
560                   grep {
561                          my $d = $_;
562                            grep { $card_types{$d} eq $_ } @conf_card_types
563                        }
564                     keys %card_types;
565   }
566
567   \%card_types;
568 }
569
570 =item generate_ps FILENAME
571
572 Returns an postscript rendition of the LaTex file, as a scalar.
573 FILENAME does not contain the .tex suffix and is unlinked by this function.
574
575 =cut
576
577 use String::ShellQuote;
578
579 sub generate_ps {
580   my $file = shift;
581
582   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
583   chdir($dir);
584
585   _pslatex($file);
586
587   system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
588     or die "dvips failed";
589
590   open(POSTSCRIPT, "<$file.ps")
591     or die "can't open $file.ps: $! (error in LaTeX template?)\n";
592
593   unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
594
595   my $ps = '';
596
597   if ( $conf->exists('lpr-postscript_prefix') ) {
598     my $prefix = $conf->config('lpr-postscript_prefix');
599     $ps .= eval qq("$prefix");
600   }
601
602   while (<POSTSCRIPT>) {
603     $ps .= $_;
604   }
605
606   close POSTSCRIPT;
607
608   if ( $conf->exists('lpr-postscript_suffix') ) {
609     my $suffix = $conf->config('lpr-postscript_suffix');
610     $ps .= eval qq("$suffix");
611   }
612
613   return $ps;
614
615 }
616
617 =item generate_pdf FILENAME
618
619 Returns an PDF rendition of the LaTex file, as a scalar.  FILENAME does not
620 contain the .tex suffix and is unlinked by this function.
621
622 =cut
623
624 use String::ShellQuote;
625
626 sub generate_pdf {
627   my $file = shift;
628
629   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
630   chdir($dir);
631
632   #system('pdflatex', "$file.tex");
633   #system('pdflatex', "$file.tex");
634   #! LaTeX Error: Unknown graphics extension: .eps.
635
636   _pslatex($file);
637
638   my $sfile = shell_quote $file;
639
640   #system('dvipdf', "$file.dvi", "$file.pdf" );
641   system(
642     "dvips -q -t letter -f $sfile.dvi ".
643     "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
644     "     -c save pop -"
645   ) == 0
646     or die "dvips | gs failed: $!";
647
648   open(PDF, "<$file.pdf")
649     or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
650
651   unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
652
653   my $pdf = '';
654   while (<PDF>) {
655     $pdf .= $_;
656   }
657
658   close PDF;
659
660   return $pdf;
661
662 }
663
664 sub _pslatex {
665   my $file = shift;
666
667   #my $sfile = shell_quote $file;
668
669   my @cmd = (
670     'latex',
671     '-interaction=batchmode',
672     '\AtBeginDocument{\RequirePackage{pslatex}}',
673     '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
674     '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
675     '\PSLATEXTMP',
676     "$file.tex"
677   );
678
679   my $timeout = 30; #? should be more than enough
680
681   for ( 1, 2 ) {
682
683     local($SIG{CHLD}) = sub {};
684     run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
685       or die "pslatex $file.tex failed; see $file.log for details?\n";
686
687   }
688
689 }
690
691 =item print ARRAYREF
692
693 Sends the lines in ARRAYREF to the printer.
694
695 =cut
696
697 sub do_print {
698   my $data = shift;
699
700   my $lpr = $conf->config('lpr');
701
702   my $outerr = '';
703   run3 $lpr, $data, \$outerr, \$outerr;
704   if ( $? ) {
705     $outerr = ": $outerr" if length($outerr);
706     die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
707   }
708
709 }
710
711 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
712
713 Converts the filehandle referenced by FILEREF from fixed length record
714 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
715 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs.  Each
716 should return the value to be substituted in place of its single argument.
717
718 Returns false on success or an error if one occurs.
719
720 =cut
721
722 sub csv_from_fixed {
723   my( $fhref, $countref, $lengths, $callbacks) = @_;
724
725   eval { require Text::CSV_XS; };
726   return $@ if $@;
727
728   my $ofh = $$fhref;
729   my $unpacker = new Text::CSV_XS;
730   my $total = 0;
731   my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
732
733   my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
734   my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
735                            DIR      => $dir,
736                            UNLINK   => 0,
737                          ) or return "can't open temp file: $!\n"
738     if $template;
739
740   while ( defined(my $line=<$ofh>) ) {
741     $$countref++;
742     if ( $template ) {
743       my $column = 0;
744
745       chomp $line;
746       return "unexpected input at line $$countref: $line".
747              " -- expected $total but received ". length($line)
748         unless length($line) == $total;
749
750       $unpacker->combine( map { my $i = $column++;
751                                 defined( $callbacks->[$i] )
752                                   ? &{ $callbacks->[$i] }( $_ )
753                                   : $_
754                               } unpack( $template, $line )
755                         )
756         or return "invalid data for CSV: ". $unpacker->error_input;
757
758       print $fh $unpacker->string(), "\n"
759         or return "can't write temp file: $!\n";
760     }
761   }
762
763   if ( $template ) { close $$fhref; $$fhref = $fh }
764
765   seek $$fhref, 0, 0;
766   '';
767 }
768
769
770 =back
771
772 =head1 BUGS
773
774 This package exists.
775
776 =head1 SEE ALSO
777
778 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
779
780 L<Fax::Hylafax::Client>
781
782 =cut
783
784 1;