4 use vars qw ( @ISA @EXPORT_OK $DEBUG $DISABLE_ALL_NOTICES );
8 use IPC::Run qw( run timeout ); # for _pslatex
9 use IPC::Run3; # for do_print... should just use IPC::Run i guess
13 #do NOT depend on any FS:: modules here, causes weird (sometimes unreproducable
14 #until on client machine) dependancy loops. put them in FS::Misc::Something
17 @ISA = qw( Exporter );
18 @EXPORT_OK = qw( send_email generate_email send_fax
19 states_hash counties cities state_label
22 generate_ps generate_pdf do_print
32 FS::Misc - Miscellaneous subroutines
36 use FS::Misc qw(send_email);
42 Miscellaneous subroutines. This module contains miscellaneous subroutines
43 called from multiple other modules. These are not OO or necessarily related,
44 but are collected here to eliminate code duplication.
46 =head1 DISABLE ALL NOTICES
48 Set $FS::Misc::DISABLE_ALL_NOTICES to suppress:
52 =item FS::cust_bill::send_csv
54 =item FS::cust_bill::spool_csv
56 =item FS::msg_template::email::send_prepared
58 =item FS::Misc::send_email
60 =item FS::Misc::do_print
62 =item FS::Misc::send_fax
64 =item FS::Template_Mixin::postal_mail_fsinc
70 $DISABLE_ALL_NOTICES = 0;
76 =item send_email OPTION => VALUE ...
88 (required) comma-separated scalar or arrayref of recipients
96 (optional) MIME type for the body
100 (required unless I<nobody> is true) arrayref of body text lines
104 (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().
108 (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,
109 I<content-type>, if specified, overrides the default "multipart/mixed" for the outermost MIME container.
111 =item content-encoding
113 (optional) when using nobody, optional top-level MIME
114 encoding which, if specified, overrides the default "7bit".
118 (optional) type parameter for multipart/related messages
122 (optional) L<FS::cust_main> key; if passed, the message will be logged
123 (if logging is enabled) with this custnum.
127 (optional) L<FS::msg_template> key, for logging.
133 use vars qw( $conf );
136 use Email::Sender::Simple qw(sendmail);
137 use Email::Sender::Transport::SMTP;
140 FS::UID->install_callback( sub {
141 $conf = new FS::Conf;
147 if ( $DISABLE_ALL_NOTICES ) {
148 warn 'send_email() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
153 my %doptions = %options;
154 $doptions{'body'} = '(full body not shown in debug)';
155 warn "FS::Misc::send_email called with options:\n ". Dumper(\%doptions);
156 # join("\n", map { " $_: ". $options{$_} } keys %options ). "\n"
159 my @to = ref($options{to}) ? @{ $options{to} } : ( $options{to} );
163 if ( $options{'nobody'} ) {
165 croak "'mimeparts' option required when 'nobody' option given\n"
166 unless $options{'mimeparts'};
168 @mimeparts = @{$options{'mimeparts'}};
171 'Type' => ( $options{'content-type'} || 'multipart/mixed' ),
172 'Encoding' => ( $options{'content-encoding'} || '7bit' ),
177 @mimeparts = @{$options{'mimeparts'}}
178 if ref($options{'mimeparts'}) eq 'ARRAY';
180 if (scalar(@mimeparts)) {
183 'Type' => 'multipart/mixed',
184 'Encoding' => '7bit',
187 unshift @mimeparts, {
188 'Type' => ( $options{'content-type'} || 'text/plain' ),
189 'Charset' => 'UTF-8',
190 'Data' => ( $options{'content-type'} =~ /^text\//
191 ? Encode::encode_utf8( $options{'body'} )
194 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
195 'Disposition' => 'inline',
201 'Type' => ( $options{'content-type'} || 'text/plain' ),
202 'Data' => ( $options{'content-type'} =~ /^text\//
203 ? Encode::encode_utf8( $options{'body'} )
206 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
207 'Charset' => 'UTF-8',
214 my $from = $options{from};
215 $from =~ s/^\s*//; $from =~ s/\s*$//;
216 if ( $from =~ /^(.*)\s*<(.*@.*)>$/ ) {
222 if ( $from =~ /\@([\w\.\-]+)/ ) {
225 warn 'no domain found in invoice from address '. $options{'from'}.
226 '; constructing Message-ID (and saying HELO) @example.com';
227 $domain = 'example.com';
229 my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
232 my $message = MIME::Entity->build(
233 'From' => $options{'from'},
234 'To' => join(', ', @to),
235 'Sender' => $options{'from'},
236 'Reply-To' => $options{'from'},
237 'Date' => time2str("%a, %d %b %Y %X %z", $time),
238 'Subject' => Encode::encode('MIME-Header', $options{'subject'}),
239 'Message-ID' => "<$message_id>",
243 if ( $options{'type'} ) {
244 #false laziness w/cust_bill::generate_email
245 $message->head->replace('Content-type',
247 '; boundary="'. $message->head->multipart_boundary. '"'.
248 '; type='. $options{'type'}
252 foreach my $part (@mimeparts) {
254 if ( UNIVERSAL::isa($part, 'MIME::Entity') ) {
256 warn "attaching MIME part from MIME::Entity object\n"
258 $message->add_part($part);
260 } elsif ( ref($part) eq 'HASH' ) {
262 warn "attaching MIME part from hashref:\n".
263 join("\n", map " $_: ".$part->{$_}, keys %$part ). "\n"
265 $message->attach(%$part);
268 croak "mimepart $part isn't a hashref or MIME::Entity object!";
275 my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
279 my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
280 $smtp_opt{'port'} = $port;
283 if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
284 $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
285 } elsif ( defined($enc) && $enc eq 'starttls') {
286 $error = "SMTP settings misconfiguration: STARTTLS enabled in ".
287 "smtp-encryption but smtp-username or smtp-password missing";
290 if ( defined($enc) ) {
291 $smtp_opt{'ssl'} = 'starttls' if $enc eq 'starttls';
292 $smtp_opt{'ssl'} = 1 if $enc eq 'tls';
295 my $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
297 push @to, $options{bcc} if defined($options{bcc});
298 # fully unpack all addresses found in @to (including Bcc) to make the
301 foreach my $dest (@to) {
302 push @env_to, map { $_->address } Email::Address->parse($dest);
305 unless ( length($error) ) {
307 local $SIG{__DIE__}; # don't want Mason __DIE__ handler active
308 local $@; # just in case
309 eval { sendmail($message, { transport => $transport,
313 if (ref($@) and $@->isa('Email::Sender::Failure')) {
314 $error = $@->code.' ' if $@->code;
315 $error .= $@->message;
323 if ( $conf->exists('log_sent_mail') ) {
324 my $cust_msg = FS::cust_msg->new({
325 'env_from' => $options{'from'},
326 'env_to' => join(', ', @env_to),
327 'header' => $message->header_as_string,
328 'body' => $message->body_as_string,
331 'custnum' => $options{'custnum'},
332 'msgnum' => $options{'msgnum'},
333 'status' => ($error ? 'failed' : 'sent'),
334 'msgtype' => $options{'msgtype'},
336 my $log_error = $cust_msg->insert;
337 warn "Error logging message: $log_error\n" if $log_error; # at least warn
344 =item generate_email OPTION => VALUE ...
352 Sender address, required
356 Recipient address, required
360 Blind copy address, optional
364 email subject, required
368 Email body (HTML alternative). Arrayref of lines, or scalar.
370 Will be placed inside an HTML <BODY> tag.
374 Email body (Text alternative). Arrayref of lines, or scalar.
376 =item custnum, msgnum (optional)
378 Customer and template numbers, passed through to send_email for logging.
382 Constructs a multipart message from text_body and html_body.
386 #false laziness w/FS::cust_bill::generate_email
394 my $me = '[FS::Misc::generate_email]';
396 my @fields = qw(from to bcc subject custnum msgnum msgtype);
398 @return{@fields} = @args{@fields};
400 warn "$me creating HTML/text multipart message"
403 $return{'nobody'} = 1;
405 my $alternative = build MIME::Entity
406 'Type' => 'multipart/alternative',
407 'Encoding' => '7bit',
408 'Disposition' => 'inline'
412 if ( ref($args{'text_body'}) eq 'ARRAY' ) {
413 $data = join("\n", @{ $args{'text_body'} });
415 $data = $args{'text_body'};
418 $alternative->attach(
419 'Type' => 'text/plain',
420 'Encoding' => 'quoted-printable',
421 'Charset' => 'UTF-8',
422 #'Encoding' => '7bit',
423 'Data' => Encode::encode_utf8($data),
424 'Disposition' => 'inline',
428 if ( ref($args{'html_body'}) eq 'ARRAY' ) {
429 @html_data = @{ $args{'html_body'} };
431 @html_data = split(/\n/, $args{'html_body'});
434 $alternative->attach(
435 'Type' => 'text/html',
436 'Encoding' => 'quoted-printable',
437 'Data' => [ '<html>',
440 ' '. encode_entities($return{'subject'}),
443 ' <body bgcolor="#ffffff">',
444 ( map Encode::encode_utf8($_), @html_data ),
448 'Disposition' => 'inline',
449 #'Filename' => 'invoice.pdf',
452 #no other attachment:
454 # multipart/alternative
458 $return{'content-type'} = 'multipart/related';
459 $return{'mimeparts'} = [ $alternative ];
460 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
461 #$return{'disposition'} = 'inline';
467 =item send_fax OPTION => VALUE ...
471 I<dialstring> - (required) 10-digit phone number w/ area code
473 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
477 I<docfile> - (required) Filename of PostScript TIFF Class F document
479 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
488 die 'HylaFAX support has not been configured.'
489 unless $conf->exists('hylafax');
491 if ( $DISABLE_ALL_NOTICES ) {
492 warn 'send_fax() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
497 require Fax::Hylafax::Client;
501 if ($@ =~ /^Can't locate Fax.*/) {
502 die "You must have Fax::Hylafax::Client installed to use invoice faxing."
508 my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
510 die 'Called send_fax without a \'dialstring\'.'
511 unless exists($options{'dialstring'});
513 if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
514 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
515 my $fh = new File::Temp(
516 TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
519 ) or die "can't open temp file: $!\n";
521 $options{docfile} = $fh->filename;
523 print $fh @{$options{'docdata'}};
526 delete $options{'docdata'};
529 die 'Called send_fax without a \'docfile\' or \'docdata\'.'
530 unless exists($options{'docfile'});
532 #FIXME: Need to send canonical dialstring to HylaFAX, but this only
535 $options{'dialstring'} =~ s/[^\d\+]//g;
536 if ($options{'dialstring'} =~ /^\d{10}$/) {
537 $options{dialstring} = '+1' . $options{'dialstring'};
539 return 'Invalid dialstring ' . $options{'dialstring'} . '.';
542 my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
544 if ($faxjob->success) {
545 warn "Successfully queued fax to '$options{dialstring}' with jobid " .
550 return 'Error while sending FAX: ' . $faxjob->trace;
555 =item states_hash COUNTRY
557 Returns a list of key/value pairs containing state (or other sub-country
558 division) abbriviations and names.
562 use FS::Record qw(qsearch);
563 use Locale::SubCountry;
568 #a hash? not expecting an explosion of business from unrecognized countries..
569 return states_hash_nosubcountry($country) if $country eq 'XC';
573 map { s/[\n\r]//g; $_; }
577 'table' => 'cust_main_county',
578 'hashref' => { 'country' => $country },
579 'extra_sql' => 'GROUP BY state',
582 #it could throw a fatal "Invalid country code" error (for example "AX")
583 my $subcountry = eval { new Locale::SubCountry($country) }
584 or return (); # ( '', '(n/a)' );
586 #"i see your schwartz is as big as mine!"
587 map { ( $_->[0] => $_->[1] ) }
588 sort { $a->[1] cmp $b->[1] }
589 map { [ $_ => state_label($_, $subcountry) ] }
593 sub states_hash_nosubcountry {
598 map { s/[\n\r]//g; $_; }
602 'table' => 'cust_main_county',
603 'hashref' => { 'country' => $country },
604 'extra_sql' => 'GROUP BY state',
607 #"i see your schwartz is as big as mine!"
608 map { ( $_->[0] => $_->[1] ) }
609 sort { $a->[1] cmp $b->[1] }
614 =item counties STATE COUNTRY
616 Returns a list of counties for this state and country.
621 my( $state, $country ) = @_;
623 map { $_ } #return num_counties($state, $country) unless wantarray;
624 sort map { s/[\n\r]//g; $_; }
627 'select' => 'DISTINCT county',
628 'table' => 'cust_main_county',
629 'hashref' => { 'state' => $state,
630 'country' => $country,
635 =item cities COUNTY STATE COUNTRY
637 Returns a list of cities for this county, state and country.
642 my( $county, $state, $country ) = @_;
644 map { $_ } #return num_cities($county, $state, $country) unless wantarray;
645 sort map { s/[\n\r]//g; $_; }
648 'select' => 'DISTINCT city',
649 'table' => 'cust_main_county',
650 'hashref' => { 'county' => $county,
652 'country' => $country,
657 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
662 my( $state, $country ) = @_;
664 unless ( ref($country) ) {
665 $country = eval { new Locale::SubCountry($country) }
670 # US kludge to avoid changing existing behaviour
671 # also we actually *use* the abbriviations...
672 my $full_name = $country->country_code eq 'US'
674 : $country->full_name($state);
676 $full_name = '' if $full_name eq 'unknown';
677 $full_name =~ s/\(see also.*\)\s*$//;
678 $full_name .= " ($state)" if $full_name;
680 $full_name || $state || '(n/a)';
686 Returns a hash reference of the accepted credit card types. Keys are shorter
687 identifiers and values are the longer strings used by the system (see
688 L<Business::CreditCard>).
695 my $conf = new FS::Conf;
698 #displayname #value (Business::CreditCard)
699 "VISA" => "VISA card",
700 "MasterCard" => "MasterCard",
701 "Discover" => "Discover card",
702 "American Express" => "American Express card",
703 "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
704 "enRoute" => "enRoute",
706 "BankCard" => "BankCard",
707 "Switch" => "Switch",
710 my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
711 if ( @conf_card_types ) {
712 #perhaps the hash is backwards for this, but this way works better for
713 #usage in selfservice
714 %card_types = map { $_ => $card_types{$_} }
717 grep { $card_types{$d} eq $_ } @conf_card_types
727 Returns a hash reference of allowed package billing frequencies.
732 tie my %freq, 'Tie::IxHash', (
733 '0' => '(no recurring fee)',
736 '2d' => 'every two days',
737 '3d' => 'every three days',
739 '2w' => 'biweekly (every 2 weeks)',
741 '45d' => 'every 45 days',
742 '2' => 'bimonthly (every 2 months)',
743 '3' => 'quarterly (every 3 months)',
744 '4' => 'every 4 months',
745 '137d' => 'every 4 1/2 months (137 days)',
746 '6' => 'semiannually (every 6 months)',
748 '13' => 'every 13 months (annually +1 month)',
749 '24' => 'biannually (every 2 years)',
750 '36' => 'triannually (every 3 years)',
751 '48' => '(every 4 years)',
752 '60' => '(every 5 years)',
753 '120' => '(every 10 years)',
758 =item generate_ps FILENAME
760 Returns an postscript rendition of the LaTex file, as a scalar.
761 FILENAME does not contain the .tex suffix and is unlinked by this function.
765 use String::ShellQuote;
770 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
775 my $papersize = $conf->config('papersize') || 'letter';
777 local($SIG{CHLD}) = sub {};
779 system('dvips', '-q', '-t', $papersize, "$file.dvi", '-o', "$file.ps" ) == 0
780 or die "dvips failed";
782 open(POSTSCRIPT, "<$file.ps")
783 or die "can't open $file.ps: $! (error in LaTeX template?)\n";
785 unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex")
786 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
790 if ( $conf->exists('lpr-postscript_prefix') ) {
791 my $prefix = $conf->config('lpr-postscript_prefix');
792 $ps .= eval qq("$prefix");
795 while (<POSTSCRIPT>) {
801 if ( $conf->exists('lpr-postscript_suffix') ) {
802 my $suffix = $conf->config('lpr-postscript_suffix');
803 $ps .= eval qq("$suffix");
810 =item generate_pdf FILENAME
812 Returns an PDF rendition of the LaTex file, as a scalar. FILENAME does not
813 contain the .tex suffix and is unlinked by this function.
817 use String::ShellQuote;
822 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
825 #system('pdflatex', "$file.tex");
826 #system('pdflatex', "$file.tex");
827 #! LaTeX Error: Unknown graphics extension: .eps.
831 my $sfile = shell_quote $file;
833 #system('dvipdf', "$file.dvi", "$file.pdf" );
834 my $papersize = $conf->config('papersize') || 'letter';
836 local($SIG{CHLD}) = sub {};
839 "dvips -q -f $sfile.dvi -t $papersize ".
840 "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
843 or die "dvips | gs failed: $!";
845 open(PDF, "<$file.pdf")
846 or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
848 unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex")
849 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
865 #my $sfile = shell_quote $file;
869 '-interaction=batchmode',
870 '\AtBeginDocument{\RequirePackage{pslatex}}',
871 '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
872 '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
877 my $timeout = 30; #? should be more than enough
881 local($SIG{CHLD}) = sub {};
882 run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
883 or warn "bad exit status from pslatex pass $_\n";
887 return if -e "$file.dvi" && -s "$file.dvi";
888 die "pslatex $file.tex failed, see $file.log for details?\n";
892 =item do_print ARRAYREF [, OPTION => VALUE ... ]
894 Sends the lines in ARRAYREF to the printer.
896 Options available are:
902 Uses this agent's 'lpr' configuration setting override instead of the global
907 Uses this command instead of the configured lpr command (overrides both the
908 global value and agentnum).
913 my( $data, %opt ) = @_;
915 if ( $DISABLE_ALL_NOTICES ) {
916 warn 'do_print() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
920 my $lpr = ( exists($opt{'lpr'}) && $opt{'lpr'} )
922 : $conf->config('lpr', $opt{'agentnum'} );
925 local($SIG{CHLD}) = sub {};
926 run3 $lpr, $data, \$outerr, \$outerr;
928 $outerr = ": $outerr" if length($outerr);
929 die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
934 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
936 Converts the filehandle referenced by FILEREF from fixed length record
937 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
938 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs. Each
939 should return the value to be substituted in place of its single argument.
941 Returns false on success or an error if one occurs.
946 my( $fhref, $countref, $lengths, $callbacks) = @_;
948 eval { require Text::CSV_XS; };
952 my $unpacker = new Text::CSV_XS;
954 my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
956 my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
957 my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
960 ) or return "can't open temp file: $!\n"
963 while ( defined(my $line=<$ofh>) ) {
969 return "unexpected input at line $$countref: $line".
970 " -- expected $total but received ". length($line)
971 unless length($line) == $total;
973 $unpacker->combine( map { my $i = $column++;
974 defined( $callbacks->[$i] )
975 ? &{ $callbacks->[$i] }( $_ )
977 } unpack( $template, $line )
979 or return "invalid data for CSV: ". $unpacker->error_input;
981 print $fh $unpacker->string(), "\n"
982 or return "can't write temp file: $!\n";
986 if ( $template ) { close $$fhref; $$fhref = $fh }
992 =item ocr_image IMAGE_SCALAR
994 Runs OCR on the provided image data and returns a list of text lines.
999 my $logo_data = shift;
1001 #XXX use conf dir location from Makefile
1002 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
1003 my $fh = new File::Temp(
1004 TEMPLATE => 'bizcard.XXXXXXXX',
1005 SUFFIX => '.png', #XXX assuming, but should handle jpg, gif, etc. too
1008 ) or die "can't open temp file: $!\n";
1010 my $filename = $fh->filename;
1012 print $fh $logo_data;
1015 local($SIG{CHLD}) = sub {};
1017 run( [qw(ocroscript recognize), $filename], '>'=>"$filename.hocr" )
1018 or die "ocroscript recognize failed\n";
1020 run( [qw(ocroscript hocr-to-text), "$filename.hocr"], '>pipe'=>\*OUT )
1021 or die "ocroscript hocr-to-text failed\n";
1023 my @lines = split(/\n/, <OUT> );
1025 foreach (@lines) { s/\.c0m\s*$/.com/; }
1030 =item bytes_substr STRING, OFFSET[, LENGTH[, REPLACEMENT] ]
1033 Use Unicode::Truncate truncate_egc instead
1035 A replacement for "substr" that counts raw bytes rather than logical
1036 characters. Unlike "bytes::substr", will suppress fragmented UTF-8 characters
1037 rather than output them. Unlike real "substr", is not an lvalue.
1041 # sub bytes_substr {
1042 # my ($string, $offset, $length, $repl) = @_;
1043 # my $bytes = substr(
1044 # Encode::encode('utf8', $string),
1047 # Encode::encode('utf8', $repl)
1049 # my $chk = $DEBUG ? Encode::FB_WARN : Encode::FB_QUIET;
1050 # return Encode::decode('utf8', $bytes, $chk);
1055 Accepts a postive or negative numerical value.
1056 Returns amount formatted for display,
1057 including money character.
1063 my $money_char = $conf->{'money_char'} || '$';
1064 $amount = sprintf("%0.2f",$amount);
1065 $amount =~ s/^(-?)/$1$money_char/;
1073 This package exists.
1077 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
1079 L<Fax::Hylafax::Client>