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