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;
138 use Email::Sender::Transport::SMTP::TLS 0.11;
141 FS::UID->install_callback( sub {
142 $conf = new FS::Conf;
148 if ( $DISABLE_ALL_NOTICES ) {
149 warn 'send_email() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
154 my %doptions = %options;
155 $doptions{'body'} = '(full body not shown in debug)';
156 warn "FS::Misc::send_email called with options:\n ". Dumper(\%doptions);
157 # join("\n", map { " $_: ". $options{$_} } keys %options ). "\n"
160 my @to = ref($options{to}) ? @{ $options{to} } : ( $options{to} );
164 if ( $options{'nobody'} ) {
166 croak "'mimeparts' option required when 'nobody' option given\n"
167 unless $options{'mimeparts'};
169 @mimeparts = @{$options{'mimeparts'}};
172 'Type' => ( $options{'content-type'} || 'multipart/mixed' ),
173 'Encoding' => ( $options{'content-encoding'} || '7bit' ),
178 @mimeparts = @{$options{'mimeparts'}}
179 if ref($options{'mimeparts'}) eq 'ARRAY';
181 if (scalar(@mimeparts)) {
184 'Type' => 'multipart/mixed',
185 'Encoding' => '7bit',
188 unshift @mimeparts, {
189 'Type' => ( $options{'content-type'} || 'text/plain' ),
190 'Charset' => 'UTF-8',
191 'Data' => ( $options{'content-type'} =~ /^text\//
192 ? Encode::encode_utf8( $options{'body'} )
195 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
196 'Disposition' => 'inline',
202 'Type' => ( $options{'content-type'} || 'text/plain' ),
203 'Data' => ( $options{'content-type'} =~ /^text\//
204 ? Encode::encode_utf8( $options{'body'} )
207 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
208 'Charset' => 'UTF-8',
215 my $from = $options{from};
216 $from =~ s/^\s*//; $from =~ s/\s*$//;
217 if ( $from =~ /^(.*)\s*<(.*@.*)>$/ ) {
223 if ( $from =~ /\@([\w\.\-]+)/ ) {
226 warn 'no domain found in invoice from address '. $options{'from'}.
227 '; constructing Message-ID (and saying HELO) @example.com';
228 $domain = 'example.com';
230 my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
233 my $message = MIME::Entity->build(
234 'From' => $options{'from'},
235 'To' => join(', ', @to),
236 'Sender' => $options{'from'},
237 'Reply-To' => $options{'from'},
238 'Date' => time2str("%a, %d %b %Y %X %z", $time),
239 'Subject' => Encode::encode('MIME-Header', $options{'subject'}),
240 'Message-ID' => "<$message_id>",
244 if ( $options{'type'} ) {
245 #false laziness w/cust_bill::generate_email
246 $message->head->replace('Content-type',
248 '; boundary="'. $message->head->multipart_boundary. '"'.
249 '; type='. $options{'type'}
253 foreach my $part (@mimeparts) {
255 if ( UNIVERSAL::isa($part, 'MIME::Entity') ) {
257 warn "attaching MIME part from MIME::Entity object\n"
259 $message->add_part($part);
261 } elsif ( ref($part) eq 'HASH' ) {
263 warn "attaching MIME part from hashref:\n".
264 join("\n", map " $_: ".$part->{$_}, keys %$part ). "\n"
266 $message->attach(%$part);
269 croak "mimepart $part isn't a hashref or MIME::Entity object!";
276 my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
280 my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
281 $smtp_opt{'port'} = $port;
284 if ( defined($enc) && $enc eq 'starttls' ) {
285 $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
286 $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
288 if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
289 $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
291 $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
292 $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
295 push @to, $options{bcc} if defined($options{bcc});
296 # fully unpack all addresses found in @to (including Bcc) to make the
299 foreach my $dest (@to) {
300 push @env_to, map { $_->address } Email::Address->parse($dest);
303 local $SIG{__DIE__}; # don't want Mason __DIE__ handler active
304 local $@; # just in case
305 eval { sendmail($message, { transport => $transport,
310 if(ref($@) and $@->isa('Email::Sender::Failure')) {
311 $error = $@->code.' ' if $@->code;
312 $error .= $@->message;
319 if ( $conf->exists('log_sent_mail') ) {
320 my $cust_msg = FS::cust_msg->new({
321 'env_from' => $options{'from'},
322 'env_to' => join(', ', @env_to),
323 'header' => $message->header_as_string,
324 'body' => $message->body_as_string,
327 'custnum' => $options{'custnum'},
328 'msgnum' => $options{'msgnum'},
329 'status' => ($error ? 'failed' : 'sent'),
330 'msgtype' => $options{'msgtype'},
332 my $log_error = $cust_msg->insert;
333 warn "Error logging message: $log_error\n" if $log_error; # at least warn
339 =item generate_email OPTION => VALUE ...
347 Sender address, required
351 Recipient address, required
355 Blind copy address, optional
359 email subject, required
363 Email body (HTML alternative). Arrayref of lines, or scalar.
365 Will be placed inside an HTML <BODY> tag.
369 Email body (Text alternative). Arrayref of lines, or scalar.
371 =item custnum, msgnum (optional)
373 Customer and template numbers, passed through to send_email for logging.
377 Constructs a multipart message from text_body and html_body.
381 #false laziness w/FS::cust_bill::generate_email
389 my $me = '[FS::Misc::generate_email]';
391 my @fields = qw(from to bcc subject custnum msgnum msgtype);
393 @return{@fields} = @args{@fields};
395 warn "$me creating HTML/text multipart message"
398 $return{'nobody'} = 1;
400 my $alternative = build MIME::Entity
401 'Type' => 'multipart/alternative',
402 'Encoding' => '7bit',
403 'Disposition' => 'inline'
407 if ( ref($args{'text_body'}) eq 'ARRAY' ) {
408 $data = join("\n", @{ $args{'text_body'} });
410 $data = $args{'text_body'};
413 $alternative->attach(
414 'Type' => 'text/plain',
415 'Encoding' => 'quoted-printable',
416 'Charset' => 'UTF-8',
417 #'Encoding' => '7bit',
418 'Data' => Encode::encode_utf8($data),
419 'Disposition' => 'inline',
423 if ( ref($args{'html_body'}) eq 'ARRAY' ) {
424 @html_data = @{ $args{'html_body'} };
426 @html_data = split(/\n/, $args{'html_body'});
429 $alternative->attach(
430 'Type' => 'text/html',
431 'Encoding' => 'quoted-printable',
432 'Data' => [ '<html>',
435 ' '. encode_entities($return{'subject'}),
438 ' <body bgcolor="#ffffff">',
439 ( map Encode::encode_utf8($_), @html_data ),
443 'Disposition' => 'inline',
444 #'Filename' => 'invoice.pdf',
447 #no other attachment:
449 # multipart/alternative
453 $return{'content-type'} = 'multipart/related';
454 $return{'mimeparts'} = [ $alternative ];
455 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
456 #$return{'disposition'} = 'inline';
462 =item send_fax OPTION => VALUE ...
466 I<dialstring> - (required) 10-digit phone number w/ area code
468 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
472 I<docfile> - (required) Filename of PostScript TIFF Class F document
474 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
483 die 'HylaFAX support has not been configured.'
484 unless $conf->exists('hylafax');
486 if ( $DISABLE_ALL_NOTICES ) {
487 warn 'send_fax() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
492 require Fax::Hylafax::Client;
496 if ($@ =~ /^Can't locate Fax.*/) {
497 die "You must have Fax::Hylafax::Client installed to use invoice faxing."
503 my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
505 die 'Called send_fax without a \'dialstring\'.'
506 unless exists($options{'dialstring'});
508 if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
509 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
510 my $fh = new File::Temp(
511 TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
514 ) or die "can't open temp file: $!\n";
516 $options{docfile} = $fh->filename;
518 print $fh @{$options{'docdata'}};
521 delete $options{'docdata'};
524 die 'Called send_fax without a \'docfile\' or \'docdata\'.'
525 unless exists($options{'docfile'});
527 #FIXME: Need to send canonical dialstring to HylaFAX, but this only
530 $options{'dialstring'} =~ s/[^\d\+]//g;
531 if ($options{'dialstring'} =~ /^\d{10}$/) {
532 $options{dialstring} = '+1' . $options{'dialstring'};
534 return 'Invalid dialstring ' . $options{'dialstring'} . '.';
537 my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
539 if ($faxjob->success) {
540 warn "Successfully queued fax to '$options{dialstring}' with jobid " .
545 return 'Error while sending FAX: ' . $faxjob->trace;
550 =item states_hash COUNTRY
552 Returns a list of key/value pairs containing state (or other sub-country
553 division) abbriviations and names.
557 use FS::Record qw(qsearch);
558 use Locale::SubCountry;
563 #a hash? not expecting an explosion of business from unrecognized countries..
564 return states_hash_nosubcountry($country) if $country eq 'XC';
568 map { s/[\n\r]//g; $_; }
572 'table' => 'cust_main_county',
573 'hashref' => { 'country' => $country },
574 'extra_sql' => 'GROUP BY state',
577 #it could throw a fatal "Invalid country code" error (for example "AX")
578 my $subcountry = eval { new Locale::SubCountry($country) }
579 or return (); # ( '', '(n/a)' );
581 #"i see your schwartz is as big as mine!"
582 map { ( $_->[0] => $_->[1] ) }
583 sort { $a->[1] cmp $b->[1] }
584 map { [ $_ => state_label($_, $subcountry) ] }
588 sub states_hash_nosubcountry {
593 map { s/[\n\r]//g; $_; }
597 'table' => 'cust_main_county',
598 'hashref' => { 'country' => $country },
599 'extra_sql' => 'GROUP BY state',
602 #"i see your schwartz is as big as mine!"
603 map { ( $_->[0] => $_->[1] ) }
604 sort { $a->[1] cmp $b->[1] }
609 =item counties STATE COUNTRY
611 Returns a list of counties for this state and country.
616 my( $state, $country ) = @_;
618 map { $_ } #return num_counties($state, $country) unless wantarray;
619 sort map { s/[\n\r]//g; $_; }
622 'select' => 'DISTINCT county',
623 'table' => 'cust_main_county',
624 'hashref' => { 'state' => $state,
625 'country' => $country,
630 =item cities COUNTY STATE COUNTRY
632 Returns a list of cities for this county, state and country.
637 my( $county, $state, $country ) = @_;
639 map { $_ } #return num_cities($county, $state, $country) unless wantarray;
640 sort map { s/[\n\r]//g; $_; }
643 'select' => 'DISTINCT city',
644 'table' => 'cust_main_county',
645 'hashref' => { 'county' => $county,
647 'country' => $country,
652 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
657 my( $state, $country ) = @_;
659 unless ( ref($country) ) {
660 $country = eval { new Locale::SubCountry($country) }
665 # US kludge to avoid changing existing behaviour
666 # also we actually *use* the abbriviations...
667 my $full_name = $country->country_code eq 'US'
669 : $country->full_name($state);
671 $full_name = '' if $full_name eq 'unknown';
672 $full_name =~ s/\(see also.*\)\s*$//;
673 $full_name .= " ($state)" if $full_name;
675 $full_name || $state || '(n/a)';
681 Returns a hash reference of the accepted credit card types. Keys are shorter
682 identifiers and values are the longer strings used by the system (see
683 L<Business::CreditCard>).
690 my $conf = new FS::Conf;
693 #displayname #value (Business::CreditCard)
694 "VISA" => "VISA card",
695 "MasterCard" => "MasterCard",
696 "Discover" => "Discover card",
697 "American Express" => "American Express card",
698 "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
699 "enRoute" => "enRoute",
701 "BankCard" => "BankCard",
702 "Switch" => "Switch",
705 my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
706 if ( @conf_card_types ) {
707 #perhaps the hash is backwards for this, but this way works better for
708 #usage in selfservice
709 %card_types = map { $_ => $card_types{$_} }
712 grep { $card_types{$d} eq $_ } @conf_card_types
722 Returns a hash reference of allowed package billing frequencies.
727 tie my %freq, 'Tie::IxHash', (
728 '0' => '(no recurring fee)',
731 '2d' => 'every two days',
732 '3d' => 'every three days',
734 '2w' => 'biweekly (every 2 weeks)',
736 '45d' => 'every 45 days',
737 '2' => 'bimonthly (every 2 months)',
738 '3' => 'quarterly (every 3 months)',
739 '4' => 'every 4 months',
740 '137d' => 'every 4 1/2 months (137 days)',
741 '6' => 'semiannually (every 6 months)',
743 '13' => 'every 13 months (annually +1 month)',
744 '24' => 'biannually (every 2 years)',
745 '36' => 'triannually (every 3 years)',
746 '48' => '(every 4 years)',
747 '60' => '(every 5 years)',
748 '120' => '(every 10 years)',
753 =item generate_ps FILENAME
755 Returns an postscript rendition of the LaTex file, as a scalar.
756 FILENAME does not contain the .tex suffix and is unlinked by this function.
760 use String::ShellQuote;
765 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
770 my $papersize = $conf->config('papersize') || 'letter';
772 local($SIG{CHLD}) = sub {};
774 system('dvips', '-q', '-t', $papersize, "$file.dvi", '-o', "$file.ps" ) == 0
775 or die "dvips failed";
777 open(POSTSCRIPT, "<$file.ps")
778 or die "can't open $file.ps: $! (error in LaTeX template?)\n";
780 unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex")
781 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
785 if ( $conf->exists('lpr-postscript_prefix') ) {
786 my $prefix = $conf->config('lpr-postscript_prefix');
787 $ps .= eval qq("$prefix");
790 while (<POSTSCRIPT>) {
796 if ( $conf->exists('lpr-postscript_suffix') ) {
797 my $suffix = $conf->config('lpr-postscript_suffix');
798 $ps .= eval qq("$suffix");
805 =item generate_pdf FILENAME
807 Returns an PDF rendition of the LaTex file, as a scalar. FILENAME does not
808 contain the .tex suffix and is unlinked by this function.
812 use String::ShellQuote;
817 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
820 #system('pdflatex', "$file.tex");
821 #system('pdflatex', "$file.tex");
822 #! LaTeX Error: Unknown graphics extension: .eps.
826 my $sfile = shell_quote $file;
828 #system('dvipdf', "$file.dvi", "$file.pdf" );
829 my $papersize = $conf->config('papersize') || 'letter';
831 local($SIG{CHLD}) = sub {};
834 "dvips -q -f $sfile.dvi -t $papersize ".
835 "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
838 or die "dvips | gs failed: $!";
840 open(PDF, "<$file.pdf")
841 or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
843 unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex")
844 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
860 #my $sfile = shell_quote $file;
864 '-interaction=batchmode',
865 '\AtBeginDocument{\RequirePackage{pslatex}}',
866 '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
867 '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
872 my $timeout = 30; #? should be more than enough
876 local($SIG{CHLD}) = sub {};
877 run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
878 or warn "bad exit status from pslatex pass $_\n";
882 return if -e "$file.dvi" && -s "$file.dvi";
883 die "pslatex $file.tex failed, see $file.log for details?\n";
887 =item do_print ARRAYREF [, OPTION => VALUE ... ]
889 Sends the lines in ARRAYREF to the printer.
891 Options available are:
897 Uses this agent's 'lpr' configuration setting override instead of the global
902 Uses this command instead of the configured lpr command (overrides both the
903 global value and agentnum).
908 my( $data, %opt ) = @_;
910 if ( $DISABLE_ALL_NOTICES ) {
911 warn 'do_print() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
915 my $lpr = ( exists($opt{'lpr'}) && $opt{'lpr'} )
917 : $conf->config('lpr', $opt{'agentnum'} );
920 local($SIG{CHLD}) = sub {};
921 run3 $lpr, $data, \$outerr, \$outerr;
923 $outerr = ": $outerr" if length($outerr);
924 die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
929 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
931 Converts the filehandle referenced by FILEREF from fixed length record
932 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
933 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs. Each
934 should return the value to be substituted in place of its single argument.
936 Returns false on success or an error if one occurs.
941 my( $fhref, $countref, $lengths, $callbacks) = @_;
943 eval { require Text::CSV_XS; };
947 my $unpacker = new Text::CSV_XS;
949 my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
951 my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
952 my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
955 ) or return "can't open temp file: $!\n"
958 while ( defined(my $line=<$ofh>) ) {
964 return "unexpected input at line $$countref: $line".
965 " -- expected $total but received ". length($line)
966 unless length($line) == $total;
968 $unpacker->combine( map { my $i = $column++;
969 defined( $callbacks->[$i] )
970 ? &{ $callbacks->[$i] }( $_ )
972 } unpack( $template, $line )
974 or return "invalid data for CSV: ". $unpacker->error_input;
976 print $fh $unpacker->string(), "\n"
977 or return "can't write temp file: $!\n";
981 if ( $template ) { close $$fhref; $$fhref = $fh }
987 =item ocr_image IMAGE_SCALAR
989 Runs OCR on the provided image data and returns a list of text lines.
994 my $logo_data = shift;
996 #XXX use conf dir location from Makefile
997 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
998 my $fh = new File::Temp(
999 TEMPLATE => 'bizcard.XXXXXXXX',
1000 SUFFIX => '.png', #XXX assuming, but should handle jpg, gif, etc. too
1003 ) or die "can't open temp file: $!\n";
1005 my $filename = $fh->filename;
1007 print $fh $logo_data;
1010 local($SIG{CHLD}) = sub {};
1012 run( [qw(ocroscript recognize), $filename], '>'=>"$filename.hocr" )
1013 or die "ocroscript recognize failed\n";
1015 run( [qw(ocroscript hocr-to-text), "$filename.hocr"], '>pipe'=>\*OUT )
1016 or die "ocroscript hocr-to-text failed\n";
1018 my @lines = split(/\n/, <OUT> );
1020 foreach (@lines) { s/\.c0m\s*$/.com/; }
1025 =item bytes_substr STRING, OFFSET[, LENGTH[, REPLACEMENT] ]
1028 Use Unicode::Truncate truncate_egc instead
1030 A replacement for "substr" that counts raw bytes rather than logical
1031 characters. Unlike "bytes::substr", will suppress fragmented UTF-8 characters
1032 rather than output them. Unlike real "substr", is not an lvalue.
1036 # sub bytes_substr {
1037 # my ($string, $offset, $length, $repl) = @_;
1038 # my $bytes = substr(
1039 # Encode::encode('utf8', $string),
1042 # Encode::encode('utf8', $repl)
1044 # my $chk = $DEBUG ? Encode::FB_WARN : Encode::FB_QUIET;
1045 # return Encode::decode('utf8', $bytes, $chk);
1050 Accepts a postive or negative numerical value.
1051 Returns amount formatted for display,
1052 including money character.
1058 my $money_char = $conf->{'money_char'} || '$';
1059 $amount = sprintf("%0.2f",$amount);
1060 $amount =~ s/^(-?)/$1$money_char/;
1068 This package exists.
1072 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
1074 L<Fax::Hylafax::Client>