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