employee (otaker / access_user) commissioning, RT#6991
[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 ( $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 $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   sort map { s/[\n\r]//g; $_; }
491        map { $_->county }
492            qsearch({
493              'select'  => 'DISTINCT county',
494              'table'   => 'cust_main_county',
495              'hashref' => { 'state'   => $state,
496                             'country' => $country,
497                           },
498            });
499 }
500
501 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
502
503 =cut
504
505 sub state_label {
506   my( $state, $country ) = @_;
507
508   unless ( ref($country) ) {
509     $country = eval { new Locale::SubCountry($country) }
510       or return'(n/a)';
511
512   }
513
514   # US kludge to avoid changing existing behaviour 
515   # also we actually *use* the abbriviations...
516   my $full_name = $country->country_code eq 'US'
517                     ? ''
518                     : $country->full_name($state);
519
520   $full_name = '' if $full_name eq 'unknown';
521   $full_name =~ s/\(see also.*\)\s*$//;
522   $full_name .= " ($state)" if $full_name;
523
524   $full_name || $state || '(n/a)';
525
526 }
527
528 =item card_types
529
530 Returns a hash reference of the accepted credit card types.  Keys are shorter
531 identifiers and values are the longer strings used by the system (see
532 L<Business::CreditCard>).
533
534 =cut
535
536 #$conf from above
537
538 sub card_types {
539   my $conf = new FS::Conf;
540
541   my %card_types = (
542     #displayname                    #value (Business::CreditCard)
543     "VISA"                       => "VISA card",
544     "MasterCard"                 => "MasterCard",
545     "Discover"                   => "Discover card",
546     "American Express"           => "American Express card",
547     "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
548     "enRoute"                    => "enRoute",
549     "JCB"                        => "JCB",
550     "BankCard"                   => "BankCard",
551     "Switch"                     => "Switch",
552     "Solo"                       => "Solo",
553   );
554   my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
555   if ( @conf_card_types ) {
556     #perhaps the hash is backwards for this, but this way works better for
557     #usage in selfservice
558     %card_types = map  { $_ => $card_types{$_} }
559                   grep {
560                          my $d = $_;
561                            grep { $card_types{$d} eq $_ } @conf_card_types
562                        }
563                     keys %card_types;
564   }
565
566   \%card_types;
567 }
568
569 =item generate_ps FILENAME
570
571 Returns an postscript rendition of the LaTex file, as a scalar.
572 FILENAME does not contain the .tex suffix and is unlinked by this function.
573
574 =cut
575
576 use String::ShellQuote;
577
578 sub generate_ps {
579   my $file = shift;
580
581   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
582   chdir($dir);
583
584   _pslatex($file);
585
586   system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
587     or die "dvips failed";
588
589   open(POSTSCRIPT, "<$file.ps")
590     or die "can't open $file.ps: $! (error in LaTeX template?)\n";
591
592   unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
593
594   my $ps = '';
595
596   if ( $conf->exists('lpr-postscript_prefix') ) {
597     my $prefix = $conf->config('lpr-postscript_prefix');
598     $ps .= eval qq("$prefix");
599   }
600
601   while (<POSTSCRIPT>) {
602     $ps .= $_;
603   }
604
605   close POSTSCRIPT;
606
607   if ( $conf->exists('lpr-postscript_suffix') ) {
608     my $suffix = $conf->config('lpr-postscript_suffix');
609     $ps .= eval qq("$suffix");
610   }
611
612   return $ps;
613
614 }
615
616 =item generate_pdf FILENAME
617
618 Returns an PDF rendition of the LaTex file, as a scalar.  FILENAME does not
619 contain the .tex suffix and is unlinked by this function.
620
621 =cut
622
623 use String::ShellQuote;
624
625 sub generate_pdf {
626   my $file = shift;
627
628   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
629   chdir($dir);
630
631   #system('pdflatex', "$file.tex");
632   #system('pdflatex', "$file.tex");
633   #! LaTeX Error: Unknown graphics extension: .eps.
634
635   _pslatex($file);
636
637   my $sfile = shell_quote $file;
638
639   #system('dvipdf', "$file.dvi", "$file.pdf" );
640   system(
641     "dvips -q -t letter -f $sfile.dvi ".
642     "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
643     "     -c save pop -"
644   ) == 0
645     or die "dvips | gs failed: $!";
646
647   open(PDF, "<$file.pdf")
648     or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
649
650   unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
651
652   my $pdf = '';
653   while (<PDF>) {
654     $pdf .= $_;
655   }
656
657   close PDF;
658
659   return $pdf;
660
661 }
662
663 sub _pslatex {
664   my $file = shift;
665
666   #my $sfile = shell_quote $file;
667
668   my @cmd = (
669     'latex',
670     '-interaction=batchmode',
671     '\AtBeginDocument{\RequirePackage{pslatex}}',
672     '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
673     '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
674     '\PSLATEXTMP',
675     "$file.tex"
676   );
677
678   my $timeout = 30; #? should be more than enough
679
680   for ( 1, 2 ) {
681
682     local($SIG{CHLD}) = sub {};
683     run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
684       or die "pslatex $file.tex failed; see $file.log for details?\n";
685
686   }
687
688 }
689
690 =item print ARRAYREF
691
692 Sends the lines in ARRAYREF to the printer.
693
694 =cut
695
696 sub do_print {
697   my $data = shift;
698
699   my $lpr = $conf->config('lpr');
700
701   my $outerr = '';
702   run3 $lpr, $data, \$outerr, \$outerr;
703   if ( $? ) {
704     $outerr = ": $outerr" if length($outerr);
705     die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
706   }
707
708 }
709
710 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
711
712 Converts the filehandle referenced by FILEREF from fixed length record
713 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
714 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs.  Each
715 should return the value to be substituted in place of its single argument.
716
717 Returns false on success or an error if one occurs.
718
719 =cut
720
721 sub csv_from_fixed {
722   my( $fhref, $countref, $lengths, $callbacks) = @_;
723
724   eval { require Text::CSV_XS; };
725   return $@ if $@;
726
727   my $ofh = $$fhref;
728   my $unpacker = new Text::CSV_XS;
729   my $total = 0;
730   my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
731
732   my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
733   my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
734                            DIR      => $dir,
735                            UNLINK   => 0,
736                          ) or return "can't open temp file: $!\n"
737     if $template;
738
739   while ( defined(my $line=<$ofh>) ) {
740     $$countref++;
741     if ( $template ) {
742       my $column = 0;
743
744       chomp $line;
745       return "unexpected input at line $$countref: $line".
746              " -- expected $total but received ". length($line)
747         unless length($line) == $total;
748
749       $unpacker->combine( map { my $i = $column++;
750                                 defined( $callbacks->[$i] )
751                                   ? &{ $callbacks->[$i] }( $_ )
752                                   : $_
753                               } unpack( $template, $line )
754                         )
755         or return "invalid data for CSV: ". $unpacker->error_input;
756
757       print $fh $unpacker->string(), "\n"
758         or return "can't write temp file: $!\n";
759     }
760   }
761
762   if ( $template ) { close $$fhref; $$fhref = $fh }
763
764   seek $$fhref, 0, 0;
765   '';
766 }
767
768
769 =back
770
771 =head1 BUGS
772
773 This package exists.
774
775 =head1 SEE ALSO
776
777 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
778
779 L<Fax::Hylafax::Client>
780
781 =cut
782
783 1;