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