3 Allows emailing one or more customers, based on a search for customers.
4 Search can be specified either through cust_main fields as cgi params, or
5 through a base64 encoded frozen hash in the 'search' cgi param. Form allows
6 selecting an existing msg_template, or creating a custom message, and shows a
7 preview of the message before sending. If linked to as a popup, include the
8 cgi parameter 'popup' for proper header handling.
10 This may also be used as an element in other pages, enabling you to provide
11 an alternate initial form while using this for search freezing/thawing and
12 preview/send actions, with the following options:
14 acl - the access right to use (defaults to 'Bulk send customer notices')
16 form_action - the URL to submit the form to
18 process_url - the URL for starting the JSRPC process
20 title - the title of the page
22 no_search_fields - arrayref of additional fields that are not search parameters
24 alternate_form - subroutine that returns alternate html for the initial form,
25 replaces msgnum/from/subject/body/action inputs and submit button, not
26 used if an action is specified
28 post_search_hook - sub hook for additional processing after search has been
29 processed from cgi, gets passed options 'conf' and 'search' (a reference to
30 the unfrozen %search hash), should be used to set msgnum or
31 from/subject/body cgi params
35 <& /elements/header-popup.html, $title &>
37 <& /elements/header.html, $title &>
40 <& /elements/error.html &>
42 <FORM NAME="OneTrueForm" ACTION="<% $form_action %>" METHOD="POST">
43 <INPUT TYPE="hidden" NAME="table" VALUE="<% $table %>">
44 %# Mixing search params with from address, subject, etc. required special-case
45 %# handling of those, risked name conflicts, and caused massive problems with
46 %# multi-valued search params. We are no longer in search context, so we
47 %# pack the search into a Storable string for later use.
48 <INPUT TYPE="hidden" NAME="search" VALUE="<% encode_base64(nfreeze(\%search)) %>">
49 <INPUT TYPE="hidden" NAME="popup" VALUE="<% $popup %>">
50 <INPUT TYPE="hidden" NAME="url" VALUE="<% $url | h %>">
51 <INPUT TYPE="hidden" NAME="to_contact_classnum" VALUE="<% join(',', @contact_classnum) %>">
53 % if ( $cgi->param('preview') ) {
54 % # preview mode: at this point we have a msg_template (either "real" or
55 % # draft) and $html_body and $text_body contain the preview message.
56 % # give the user a chance to back out (by going back to edit mode).
58 <FONT SIZE="+2">Preview notice</FONT>
59 <& /elements/progress-init.html,
61 [ qw( search table msgnum to_contact_classnum emailtovoice_contact custnum ) ],
66 <TABLE CLASS="fsinnerbox">
67 <INPUT TYPE="hidden" NAME="msgnum" VALUE="<% $msg_template->msgnum %>">
68 % # kludge these through hidden inputs because they're not really part
69 % # of the template, but should be sticky during draft editing
70 <INPUT TYPE="hidden" NAME="from_name" VALUE="<% scalar($cgi->param('from_name')) |h %>">
71 <INPUT TYPE="hidden" NAME="from_addr" VALUE="<% scalar($cgi->param('from_addr')) |h %>">
72 <INPUT TYPE="hidden" NAME="emailtovoice_contact" VALUE="<% scalar($cgi->param('emailtovoice_contact')) |h %>">
73 <INPUT TYPE="hidden" NAME="custnum" VALUE="<% scalar($cgi->param('custnum')) |h %>">
75 % if ( !$msg_template->disabled ) {
76 <& /elements/tr-td-label.html, 'label' => 'Template:' &>
77 <td><% $msg_template->msgname |h %></td>
81 <& /elements/tr-td-label.html, 'label' => 'From:' &>
82 <td><% $from |h %></td>
85 <& /elements/tr-td-label.html, 'label' => 'To contacts:' &>
86 <td><% join('<BR>', @contact_classname) %></td>
89 <& /elements/tr-td-label.html, 'label' => 'Subject:' &>
90 <td><% $subject |h %></td>
93 <TR><TD COLSPAN=2> </TD></TR>
95 <TH ALIGN="right" VALIGN="top">Message (HTML display): </TD>
96 <TD CLASS="background" ALIGN="left"><% $html_body %></TD>
99 % my $text_body = HTML::FormatText->new(leftmargin=>0)->format(
100 % HTML::TreeBuilder->new_from_content(
104 <TR><TD COLSPAN=2> </TD></TR>
106 <TH ALIGN="right" VALIGN="top">Message (Text display): </TD>
107 <TD CLASS="background" ALIGN="left">
108 <a href="javascript:void(0)" ID="email-message-text-view" style="color:#666666" onclick="showtext()">(view)</a>
109 <a href="javascript:void(0)" ID="email-message-text-hide" style="color:#666666; display: none;" onclick="hidetext()">(hide)</a>
110 <PRE id="email-message-text" style="display: none;"><% $text_body %></PRE>
118 function showtext() {
119 $('#email-message-text-view').css('display','none');
120 $('#email-message-text-hide').css('display','');
121 $('#email-message-text').slideDown();
124 function hidetext() {
125 $('#email-message-text-view').css('display','');
126 $('#email-message-text-hide').css('display','none');
127 $('#email-message-text').slideUp();
130 function areyousure(href) {
131 if (confirm("Send this notice to <% ($num_cust > 1) ? "$num_cust customers" : '1 customer' %> ?")) {
138 <INPUT TYPE="submit" NAME="edit" VALUE="Edit">
139 <INPUT TYPE="button" VALUE="Send notice" onClick="areyousure()">
141 % } elsif ($opt{'alternate_form'}) {
143 <% &{$opt{'alternate_form'}}() %>
148 <SCRIPT TYPE="text/javascript">
149 function toggle(obj) {
150 document.getElementById('table_no_template').style.display = (obj.value == 0) ? '' : 'none';
154 % if ( $msg_template and $msg_template->disabled ) {
155 % # if we've already established a draft template, don't let msgnum be changed
156 <& /elements/hidden.html,
158 curr_value => ( scalar($cgi->param('msgnum')) || ''),
162 <& /elements/select-msg_template.html,
163 onchange => 'toggle(this)',
164 curr_value => ( scalar($cgi->param('msgnum')) || ''),
168 % # select destination contact classes
169 <TABLE CELLSPACING=0 id="send_to_contacts_table">
171 <TD>Send to contacts:</TD>
173 <div id="contactclassesdiv">
174 <& /elements/checkboxes.html,
175 'style' => 'display: inline; vertical-align: top',
176 'disable_links' => 1,
177 'names_list' => \@optin_checkboxes,
178 'element_name_prefix' => 'contact_class_',
179 'checked_callback' => sub {
180 # Called for each checkbox
181 # Return true to default as checked, false as unchecked
182 my($cgi, $name) = @_;
183 exists $dest_ischecked{$name};
187 % if ($send_to_domain && $cgi->param('custnum')) {
189 <INPUT TYPE="checkbox" NAME="emailtovoice" ID="emailtovoice" VALUE="ON" onclick="toggleDiv(this)">Email to voice
191 <div id="emailtovoicediv" style="display:none";>
193 <& /elements/select-cust_phone.html,
194 'cust_num' => $cgi->param('custnum'),
195 'field_name' => 'emailtovoice_contact',
196 'format' => 'xxxxxxxxxx',
197 'phone_types' => [ 'daytime', 'night', 'fax', 'mobile' ],
198 &>@<% $send_to_domain |h %>
202 % if (@active_classes) {
205 <TD>Contact Type:</TD>
207 <div id="contactclassesdiv">
208 <& /elements/checkboxes.html,
209 'style' => 'display: inline; vertical-align: top',
210 'disable_links' => 1,
211 'names_list' => \@classnum_checkboxes,
212 'element_name_prefix' => 'contact_class_',
213 'checked_callback' => sub {
214 # Called for each checkbox
215 # Return true to default as checked, false as unchecked
216 my($cgi, $name) = @_;
217 exists $classnum_ischecked{$name};
226 % # if sending a one-off message, show a form to edit it
227 <TABLE BGCOLOR="#cccccc" CELLSPACING=0 WIDTH="100%" id="table_no_template">
228 <& /elements/tr-td-label.html, 'label' => 'From:' &>
229 <TD><& /elements/input-text.html,
230 'field' => 'from_name',
231 'value' => $conf->config('invoice_from_name', $agent_virt_agentnum) ||
232 $conf->config('company_name', $agent_virt_agentnum), #?
234 'curr_value' => scalar($cgi->param('from_name')),
236 <& /elements/input-text.html,
237 'field' => 'from_addr',
238 'type' => 'email', # HTML5, woot
239 'value' => $conf->config('invoice_from', $agent_virt_agentnum),
241 'curr_value' => scalar($cgi->param('from_addr')),
244 <& /elements/tr-input-text.html,
245 'field' => 'subject',
246 'label' => 'Subject:',
248 'curr_value' => $subject,
252 <TD ALIGN="right" VALIGN="top" STYLE="padding-top:3px">Message: </TD>
253 <TD><& /elements/htmlarea.html,
256 'curr_value' => $body,
263 <INPUT TYPE="hidden" NAME="custnum" VALUE="<% scalar($cgi->param('custnum')) |h %>">
264 <INPUT TYPE="submit" NAME="preview" VALUE="Preview notice">
266 % } #end not action or alternate form
270 <SCRIPT TYPE="text/javascript">
271 function toggleDiv(obj) {
272 var box_contactclasses = document.getElementById('contactclassesdiv');
273 var box_emailtovoice = document.getElementById('emailtovoicediv');
275 box_emailtovoice.style.display = (box_emailtovoice.style.display == 'none') ? 'block' : 'none';
276 document.getElementById('emailtovoice_contact').options[0].selected=true;
278 box_contactclasses.style.display = (box_contactclasses.style.display == 'none') ? 'block' : 'none';
282 <& /elements/footer.html &>
288 $opt{'acl'} ||= 'Bulk send customer notices';
293 unless $FS::CurrentUser::CurrentUser->access_right($opt{'acl'});
295 my $conf = FS::Conf->new;
296 my @no_search_fields = qw( table from subject html_body text_body popup url );
298 my $send_to_domain = $conf->config('email-to-voice_domain');
300 my $form_action = $opt{'form_action'} || 'email-customers.html';
301 my $process_url = $opt{'process_url'} || 'process/email-customers.html';
302 my $title = $opt{'title'} || 'Send customer notices';
303 push( @no_search_fields, @{$opt{'no_search_fields'}} ) if $opt{'no_search_fields'};
305 $m->comp('/elements/handle_uri_query');
307 my $table = $cgi->param('table') or die "'table' required";
308 my $agent_virt_agentnum = $cgi->param('agent_virt_agentnum') || '';
310 my $popup = $cgi->param('popup');
311 my $url = $cgi->param('url');
312 my $pdest = { 'message' => "Notice sent" };
313 $pdest->{'url'} = $cgi->param('url') if $url;
316 if ( $cgi->param('search') ) {
317 %search = %{ thaw(decode_base64( $cgi->param('search') )) };
320 %search = $cgi->Vars;
321 delete $search{$_} for @no_search_fields;
322 # FS::$table->search is expected to know which parameters might be
323 # multi-valued, and to accept scalar values for them also. No good
324 # solution to this since CGI can't tell whether a parameter _might_
325 # have had multiple values, only whether it does.
326 @search{keys %search} = map { /\0/ ? [ split /\0/, $_ ] : $_ } values %search;
329 # rebuild contacts hash
330 delete $search{contacts}
331 if exists $search{contacts} && ref $search{contacts};
332 $search{'contacts'} = {
333 map { $_ => $search{$_}, }
334 grep { /^(contacts_*)/ && $search{$_} }
338 &{$opt{'post_search_hook'}}(
340 'search' => \%search,
341 ) if $opt{'post_search_hook'};
345 if ( $cgi->param('from') ) {
346 $from = $cgi->param('from');
347 } elsif ( $cgi->param('from_name') ) {
348 $from = ($cgi->param('from_name') . ' <' . $cgi->param('from_addr') . '>');
349 } elsif ( $cgi->param('from_addr') ) {
350 $from = $cgi->param('from_addr');
353 my $msg_template = '';
354 if ( $cgi->param('msgnum') =~ /^(\d+)$/ ) {
355 $msg_template = FS::msg_template->by_key($1)
356 or die "template not found: ".$cgi->param('msgnum');
359 my @contact_classnum;
360 my @contact_classname;
362 my $subject = $cgi->param('subject');
363 my $body = $cgi->param('body');
364 my ($html_body, $text_body);
366 if ( !$cgi->param('preview') ) {
368 # edit mode: initialize the fields from the saved draft, if there is one
369 if ( $msg_template and $msg_template->disabled eq 'D' ) {
370 my $content = $msg_template->content(''); # no localization on these yet
371 $subject ||= $content->subject;
372 $body ||= $content->body;
375 my $sql_query = "FS::$table"->search(\%search);
376 my $count_query = delete($sql_query->{'count_query'});
377 my $count_sth = dbh->prepare($count_query)
378 or die "Error preparing $count_query: ". dbh->errstr;
380 or die "Error executing $count_query: ". $count_sth->errstr;
381 my $count_arrayref = $count_sth->fetchrow_arrayref;
382 $num_cust = $count_arrayref->[0];
386 my @checked_email_dest;
387 my @checked_contact_type;
389 if (/^contact_class_(.+)$/) {
391 if ($f eq 'invoice' || $f eq 'message') {
392 push @checked_email_dest, $f;
393 } elsif ( $f =~ /^\d+$/ ) {
394 push @checked_contact_type, $f;
398 $search{with_email_dest} = \@checked_email_dest if @checked_email_dest;
399 $search{with_contact_type} = \@checked_contact_type if @checked_contact_type;
401 my $sql_query = "FS::$table"->search(\%search);
402 my $count_query = delete($sql_query->{'count_query'});
403 my $count_sth = dbh->prepare($count_query)
404 or die "Error preparing $count_query: ". dbh->errstr;
406 or die "Error executing $count_query: ". $count_sth->errstr;
407 my $count_arrayref = $count_sth->fetchrow_arrayref;
408 $num_cust = $count_arrayref->[0];
410 if ( !$msg_template or $msg_template->disabled eq 'D' ) {
411 # then this is a one-off template; edit it in place
412 my $subject = $cgi->param('subject') || '';
413 my $body = $cgi->param('body') || '';
415 # create a draft template
416 $msg_template ||= FS::msg_template->new({
420 # anyone have a better idea for msgname?
421 $msg_template->set('msgname' => "Notice " . DateTime->now->iso8601);
422 $msg_template->set('from_addr' => $from);
428 if ( $msg_template->msgnum ) {
429 $error = $msg_template->replace(%content);
431 $error = $msg_template->insert(%content);
435 $cgi->param('error', $error);
436 $cgi->delete('preview'); # don't go on to preview stage yet
440 # unless creating the msg_template failed, we now have one, so construct a
441 # preview message from the first customer/whatever in the search results
445 if ( $msg_template ) {
446 $sql_query->{'extra_sql'} .= ' LIMIT 1';
447 $sql_query->{'select'} = "$table.*";
448 $sql_query->{'order_by'} = '';
449 my $object = qsearchs($sql_query);
450 # Could use better error handling here...
451 die "No customers match the search criteria" unless ref $object;
452 $cust = $object->cust_main;
454 'cust_main' => $cust,
458 my $cust_msg = $msg_template->prepare(%msgopts);
459 $from = $cust_msg->env_from;
460 $html_body = $cust_msg->preview;
461 #hmm. this came in with the #37098 rewrite, but isn't on v3 :/
462 # causing problems with mangling subject of unrelated things
463 # should probably decode instead of ignore the UTF-8 thing, but
464 # this at least masks the ugliness for now :/
465 if ( $cust_msg->header =~ /^subject: (.*)/mi && $1 !~ /^\=\?UTF-8/ ) {
470 # contact_class_X params
471 #we can't switch to multi_param until we're done supporting deb 7
472 local($CGI::LIST_CONTEXT_WARN) = 0;
474 if ($cgi->param('emailtovoice_contact')) {
475 $email_to = $cgi->param('emailtovoice_contact') . '@' . $send_to_domain;
476 push @contact_classnum, 'emailtovoice';
477 push @contact_classname, $email_to;
480 foreach my $param ( $cgi->param ) {
481 if ( $param =~ /^contact_class_(\w+)$/ ) {
482 push @contact_classnum, $1;
483 if ( $1 eq 'invoice' ) {
484 push @contact_classname, 'Invoice recipients';
485 } elsif ( $1 eq 'message' ) {
486 push @contact_classname, 'Message recipients';
488 my $contact_class = FS::contact_class->by_key($1);
489 push @contact_classname, encode_entities(
490 $contact_class ? $contact_class->classname : '(none)'
498 # Build data structures for "Opt In" and "Contact Type" checkboxes
500 # By default, message recipients will be selected, this is a message.
501 # By default, all Contact Types will be selected, but this may be
502 # overridden by passing 'classnums' get/post values. If no contact
503 # types have been defined, the option will not be presented.
505 my @active_classes = qsearch(contact_class => {disabled => ''} );
507 my %classnum_ischecked;
510 $CGI::LIST_CONTEXT_WARN = 0;
511 if ( my @in_classnums = $cgi->param('classnums') ) {
512 # Set checked boxes from form input
513 for my $v (@in_classnums) {
515 if ( $v =~ /^\d+$/ ) {
516 $classnum_ischecked{$v} = 1
517 } elsif ( $v =~ /^(invoice|message)$/ ) {
518 $dest_ischecked{$v} = 1;
523 # Checked boxes default values
524 $classnum_ischecked{$_->classnum} = 1 for @active_classes;
525 $classnum_ischecked{0} = 1;
528 # At least one destination is required
529 $dest_ischecked{message} = 1 unless %dest_ischecked;
531 my @optin_checkboxes = (
532 [ 'message' => { label => 'Message recipients', desc => 'send this notice to all message recipients for the '.$num_cust.' selected customers.' } ],
533 [ 'invoice' => { label => 'Invoice recipients', desc => 'send this notice to all invoice recipients for the '.$num_cust.' selected customers.' } ],
535 my @classnum_checkboxes = (
536 [ '0' => { label => '(None)' }],
537 map { [ $_->classnum => {label => $_->classname} ] } @active_classes,