[freeside-commits] branch FREESIDE_4_BRANCH updated. 50e00caf8ba629f85beaa499197947716dd30733

Mark Wells mark at 420.am
Sun Aug 30 17:49:33 PDT 2015


The branch, FREESIDE_4_BRANCH has been updated
       via  50e00caf8ba629f85beaa499197947716dd30733 (commit)
       via  eda12d4d82a25ba333fb4e822aef9704b3ae938a (commit)
       via  2d8749c581dce1c2564487b87425b60cbb37a690 (commit)
       via  179b05af5cc54a0295c65323b0a23b6c9426d6a5 (commit)
       via  5a2a2f8a6f95738758b43cdbbfa48dd7830de2d5 (commit)
       via  11c81c66b62ac176c167583f7b68ed80bd4239c9 (commit)
      from  4b4fed44031a856ab637036a91ee3f191ae4cab1 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 50e00caf8ba629f85beaa499197947716dd30733
Author: Mark Wells <mark at freeside.biz>
Date:   Sat Aug 29 21:27:39 2015 -0700

    fix improper relative paths, incidental to #21564

diff --git a/httemplate/elements/htmlarea.html b/httemplate/elements/htmlarea.html
index 7c40e61..d8b2512 100644
--- a/httemplate/elements/htmlarea.html
+++ b/httemplate/elements/htmlarea.html
@@ -12,7 +12,7 @@ Example:
 </%doc>
 
 % #init
-<SCRIPT TYPE="text/javascript" src="<% $p %>elements/ckeditor/ckeditor.js">
+<SCRIPT TYPE="text/javascript" src="<% $fsurl %>elements/ckeditor/ckeditor.js">
 </SCRIPT>
 
 % #editor
@@ -35,7 +35,7 @@ my $config = {
   'skin'                => 'kama',
   'toolbarCanCollapse'  => JSON::true,
   'removePlugins'       => 'elementspath',
-  'basePath'            => $p.'elements/ckeditor/',
+  'basePath'            => $fsurl.'elements/ckeditor/',
   'enterMode'           => 2,
   %{ $opt{config} || {} },
 };
diff --git a/httemplate/elements/template_image-dialog.html b/httemplate/elements/template_image-dialog.html
index f7fb0c2..b471d28 100644
--- a/httemplate/elements/template_image-dialog.html
+++ b/httemplate/elements/template_image-dialog.html
@@ -14,7 +14,7 @@ url - to redirect to after upload, otherwise just refreshes dialog window
 </%doc>
 
 <% include('/elements/xmlhttp.html',
-        'url'  => $p.'misc/xmlhttp-template_image.cgi',
+        'url'  => $fsurl.'misc/xmlhttp-template_image.cgi',
         'subs' => [ 'get_template_image' ],
    ) %>
 
@@ -75,7 +75,7 @@ url - to redirect to after upload, otherwise just refreshes dialog window
 <% include('/elements/form-file_upload.html',
      'name'      => 'TemplateImageUploadForm',
      'id'        => 'TemplateImageUploadForm',
-     'action'    => $p.'misc/process/template_image-upload.cgi',
+     'action'    => $fsurl.'misc/process/template_image-upload.cgi',
      'num_files' => 1,
      'fields'    => [ 'name', 'agentnum' ],
      'url'       => $opt{'url'} || 'javascript:refreshImageList(1)',

commit eda12d4d82a25ba333fb4e822aef9704b3ae938a
Author: Mark Wells <mark at freeside.biz>
Date:   Sat Aug 29 21:27:20 2015 -0700

    #21564: user interface for REST client

diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm
index 827bb98..4c2ac4b 100644
--- a/FS/FS/msg_template.pm
+++ b/FS/FS/msg_template.pm
@@ -5,7 +5,7 @@ use strict;
 use vars qw( $DEBUG $conf );
 
 use FS::Conf;
-use FS::Record qw( qsearch qsearchs );
+use FS::Record qw( qsearch qsearchs dbh );
 
 use FS::cust_msg;
 use FS::template_content;
@@ -95,11 +95,16 @@ sub _rebless {
   eval "use $class;";
   bless($self, $class) unless $@;
 
-  # merge in the extension fields
+  # merge in the extension fields (but let fields in $self override them)
+  # except don't ever override the extension's primary key, it's immutable
   if ( $self->msgnum and $self->extension_table ) {
     my $extension = $self->_extension;
     if ( $extension ) {
-      $self->{Hash} = { $self->hash, $extension->hash };
+      my $ext_key = $extension->get($extension->primary_key);
+      $self->{Hash} = { $extension->hash,
+                        $self->hash,
+                        $extension->primary_key => $ext_key
+                      };
     }
   }
 
@@ -194,6 +199,8 @@ sub replace {
 
   my $extension = $new->_extension;
   if ( $extension ) {
+    # merge changes into the extension record and replace it
+    $extension->{Hash} = { $extension->hash, $new->hash };
     $error ||= $extension->replace;
   }
 
@@ -212,7 +219,7 @@ sub replace_check {
   if ( $old->msgclass ) {
     if ( !$self->msgclass ) {
       $self->set('msgclass', $old->msgclass);
-    } else {
+    } elsif ( $old->msgclass ne $self->msgclass ) {
       return "Can't change message template class from ".$old->msgclass.
              " to ".$self->msgclass.".";
     }
diff --git a/httemplate/browse/msg_template.html b/httemplate/browse/msg_template/email.html
similarity index 70%
rename from httemplate/browse/msg_template.html
rename to httemplate/browse/msg_template/email.html
index 1646bc1..d0ef4e3 100644
--- a/httemplate/browse/msg_template.html
+++ b/httemplate/browse/msg_template/email.html
@@ -1,9 +1,9 @@
-<% include( 'elements/browse.html',
+<& /browse/elements/browse.html,
               'title'         => 'Message templates',
               'name_singular' => 'template',
               'menubar'       => \@menubar,
-              'query'         => { 'table' => 'msg_template', },
-              'count_query'   => 'SELECT COUNT(*) FROM msg_template',
+              'query'         => $query,
+              'count_query'   => $count_query,
               'disableable'   => 1,
               'disabled_statuspos' => (scalar(@locales) + 3),
               'agent_virt'         => 1,
@@ -14,8 +14,7 @@
               'links'       => [ $link, @locale_links, '' ],
               'link_onclicks' => [ '', map('', @locale_links), $disable_link ],
               'cell_style'    => [ '', '', map ($locale_style, @locales), $locale_style ],
-          )
-%>
+&>
 <%init>
 
 my $curuser = $FS::CurrentUser::CurrentUser;
@@ -26,11 +25,21 @@ die "access denied"
 
 my @menubar = ();
 if ( $curuser->access_right(['Edit templates', 'Edit global templates']) ) {
-  push @menubar, 'Add a new template' => $p.'edit/msg_template.html';
+  push @menubar, 'Add a new template' => $fsurl.'edit/msg_template/email.html';
 }
-push @menubar, 'View template images' => $p.'browse/template_image.html';
+push @menubar, 'Template images' => $fsurl.'browse/template_image.html';
 
-my $link = [ "${p}edit/msg_template.html?msgnum=", 'msgnum' ];
+push @menubar, 'External message interfaces' => $fsurl.'browse/msg_template/http.html';
+
+my $query = {
+  'table'   => 'msg_template',
+  'select'  => '*',
+  'hashref' => { 'msgclass' => 'email' },
+};
+
+my $count_query = "SELECT COUNT(*) FROM msg_template WHERE msgclass = 'email'";
+
+my $link = [ $fsurl.'edit/msg_template/email.html?msgnum=', 'msgnum' ];
 
 my $locale_style = 'font-size:0.8em; padding:3px';
 
@@ -43,14 +52,14 @@ foreach my $l ( FS::Locales->locales ) {
   };
   push @locale_links, sub {
     my $content = $_[0]->content_locales->{$l} or return '';
-    [ "${p}edit/msg_template.html?locale=$l;msgnum=", 'msgnum' ];
+    [ $fsurl."edit/msg_template/email.html?locale=$l;msgnum=", 'msgnum' ];
   };
 }
 
 my $disable_link = sub {
   my $template = shift;
   include('/elements/popup_link_onclick.html',
-    action      => $p.'misc/disable-msg_template.cgi?msgnum=' .
+    action      => $fsurl.'misc/disable-msg_template.cgi?msgnum=' .
                      $template->msgnum .
                      ($template->disabled ? ';enable=1' : ''),
     actionlabel => 'Disable template',
diff --git a/httemplate/browse/msg_template/http.html b/httemplate/browse/msg_template/http.html
new file mode 100644
index 0000000..888fda4
--- /dev/null
+++ b/httemplate/browse/msg_template/http.html
@@ -0,0 +1,68 @@
+<& /browse/elements/browse.html,
+              'title'         => 'External message interfaces',
+              'name_singular' => 'interface', # what else do we call them?
+              'menubar'       => \@menubar,
+              'query'         => $query,
+              'count_query'   => $count_query,
+              'disableable'   => 1,
+              'disabled_statuspos' => 4,
+              'agent_virt'         => 1,
+              'agent_null_right'   => ['View global templates','Edit global templates'],
+              'agent_pos'          => 1,
+              'header'      => [ 'Name',
+                                 # 'Agent',
+                                 'Prepare',
+                                 'Send',
+                                 '' ],
+              'fields'      => [ 'msgname',
+                                 'prepare_url',
+                                 'send_url',
+                                 $disable_link_label
+                               ],
+              'links'       => [ $link, ],
+              'link_onclicks' => [ '', '', '',  $disable_link ],
+              'cell_style'  => [ '', '', $url_style, $url_style ],
+&>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right([ 'View templates', 'View global templates',
+                                  'Edit templates', 'Edit global templates', ]);
+
+my @menubar = ();
+if ( $curuser->access_right(['Edit templates', 'Edit global templates']) ) {
+  push @menubar, 'Add a new interface' => $fsurl.'edit/msg_template/http.html';
+}
+push @menubar, 'Email templates' => $fsurl.'browse/msg_template/email.html';
+push @menubar, 'Template images' => $fsurl.'browse/template_image.html';
+
+my $query = {
+  'table'   => 'msg_template',
+  'select'  => '*',
+  'hashref' => { 'msgclass' => 'http' },
+};
+
+my $count_query = "SELECT COUNT(*) FROM msg_template WHERE msgclass = 'http'";
+
+my $link = [ $fsurl.'edit/msg_template/http.html?msgnum=', 'msgnum' ];
+
+my $url_style = 'font-size:0.8em; padding:3px'; # also for (disable) label
+
+my $disable_link = sub {
+  my $template = shift;
+  include('/elements/popup_link_onclick.html',
+    action      => $fsurl.'misc/disable-msg_template.cgi?msgnum=' .
+                     $template->msgnum .
+                     ($template->disabled ? ';enable=1' : ''),
+    actionlabel => 'Disable template',
+  );
+};
+
+my $disable_link_label = sub {
+  my $template = shift;
+  $template->disabled ? '(enable)' : '(disable)' ;
+};
+
+</%init>
diff --git a/httemplate/edit/msg_template.html b/httemplate/edit/msg_template.html
index df72c5b..889b107 100644
--- a/httemplate/edit/msg_template.html
+++ b/httemplate/edit/msg_template.html
@@ -1,380 +1,9 @@
-<& elements/edit.html,
-     'html_init'        => '<TABLE id="outerTable"><TR><TD>',
-     'body_etc'         => $body_etc,
-     'name_singular'    => 'template',
-     'table'            => 'msg_template',
-     'viewall_dir'      => 'browse',
-     'agent_virt'       => 1,
-     'agent_null'       => 1,
-     'agent_null_right' => [ 'View global templates', 'Edit global templates' ],
-
-     'fields'           => \@fields,
-     'labels'           => { 
-                             'msgnum'    => 'Template',
-                             'agentnum'  => 'Agent',
-                             'msgname'   => 'Template name',
-                             'from_addr' => 'From: ',
-                             'bcc_addr'  => 'Bcc: ',
-                             'locale'    => 'Locale',
-                             'subject'   => 'Subject: ',
-                             'body'      => 'Message body',
-                           },
-     'edit_callback'    => \&edit_callback,
-     'error_callback'   => \&edit_callback,
-     'html_bottom'      => '</DIV>',
-     'html_table_bottom'=> \&html_table_bottom,
-     'html_foot'        => ( $no_submit ? '' : "</TD>$sidebar</TR></TABLE>" ),
-     'no_submit'        => $no_submit,
-&>
 <%init>
-use FS::template_image;
-
-my $curuser = $FS::CurrentUser::CurrentUser;
-
-die "access denied"
-  unless $curuser->access_right([ 'View templates', 'View global templates',
-                                  'Edit templates', 'Edit global templates',
-                               ]);
-
-my $body_etc = '';
-$body_etc = q!onload="document.getElementById('locale').onchange()"!
-  if $cgi->param('locale') eq 'new';
-
-my $msgnum = $cgi->param('msgnum');
-my $msg_template = $msgnum ? qsearchs('msg_template', {msgnum=>$msgnum}) : '';
-
-my $no_submit = 0;
-my @fields = ();
-if ( $curuser->access_right('Edit global templates') 
-     || (    $curuser->access_right('Edit templates')
-          && $msg_template
-          && $msg_template->agentnum
-          && $curuser->agentnums_href->{$msg_template->agentnum}
-        )
-   )
-{
-  push @fields,
-      { field => 'agentnum',
-        type  => 'select-agent',
-      },
-      { field => 'msgname',   size=>60, },
-      { field => 'from_addr', size=>60, },
-      { field => 'bcc_addr',  size=>60, },
-      { type  => 'tablebreak-tabs',
-        include_opt_callback => \&menubar_opt_callback,
-      },
-      # template_content fields
-      { field => 'locale', type => 'hidden' },
-      { field => 'subject',   size=>60, },
-      { field => 'body',
-        type  => 'htmlarea',
-        width => 763,
-        config=> { extraPlugins => 'blockprotect' },
-      },
-  ;
-} else { #readonly
-
-  $no_submit = 1;
-
-  push @fields,
-      { field => 'agentnum',
-        type  => 'select-agent',
-        fixed => 1,
-      },
-      { field => 'msgname',   type => 'fixed', },
-      { field => 'from_addr', type => 'fixed', },
-      { field => 'bcc_addr',  type => 'fixed', },
-      { type  => 'tablebreak-tabs',
-        include_opt_callback => \&menubar_opt_callback,
-      },
-      # template_content fields
-      { field => 'locale',  type => 'hidden' },
-      { field => 'subject', type => 'fixed', },
-      { field    => 'body',
-        type     => 'fixed',
-        noescape => 1,
-      },
-  ;
-
+my $msgclass = 'email';
+if ( $cgi->param('msgnum') =~ /^(\d+)$/ ) {
+  my $msg_template = FS::msg_template->by_key($1)
+    or die "unknown msgnum $1";
+  $msgclass = $msg_template->msgclass;
 }
-
-sub new_callback {
-  my ($cgi, $object, $fields_listref, $opt_hashref) = @_;
-  my $template_content = new FS::template_content { 'locale' => '' };
-  $object->{'Hash'} = { $object->hash, $template_content->hash };
-}
-
-sub edit_callback {
-  my ($cgi, $object, $fields_listref, $opt_hashref) = @_;
-  $cgi->param('locale') =~ /^(\w*)$/ or die 'bad locale '.$cgi->param('locale');
-  my $locale = $1;
-
-  # fetch the content object and merge its fields
-  my %args = (
-    'msgnum' => $object->msgnum,
-    'locale' => $locale
-  );
-  my $template_content = qsearchs('template_content', \%args) 
-                        || new FS::template_content( { %args });
-  $object->{'Hash'} = { $object->hash, $template_content->hash };
-
-  # set up the locale selector if this is a new content
-  if ( $locale eq 'new' ) {
-
-    # make a list of available locales
-    my $content_locales = $object->content_locales;
-    my @locales = grep { !exists($content_locales->{$_}) } 
-                         FS::Conf->new->config('available-locales');
-    my %labels;
-    foreach (@locales) {
-      my %info = FS::Locales->locale_info($_);
-      $labels{$_} = $info{'label'};
-    }
-    unshift @locales, 'new';
-    $labels{'new'} = 'Select language';
-
-    # insert a field def
-    my $i = 0;
-    $i++ until ( $fields_listref->[$i]->{'field'} eq 'locale' );
-    my $locale_field = $fields_listref->[$i];
-
-    my $onchange_locale = "document.getElementById('submit').disabled = 
-    (this.options[this.selectedIndex].value == 'new');";
-
-    %$locale_field = (
-      field   => 'locale',
-      type    => 'select',
-      options => \@locales,
-      labels  => \%labels,
-      curr_value  => 'new',
-      onchange    => $onchange_locale,
-    );
-  }
-}
-
-sub menubar_opt_callback {
-  my $object = shift;
-  # generate no tabs for new msg_templates.
-  my $msgnum = $object->msgnum or return; 
-  my (@tabs, @options, %labels);
-  push @tabs, mt('Default'), '';
-  my $display_new = 0;
-  my $selected = '';
-  foreach my $l (FS::Locales->locales) {
-    if ( exists $object->content_locales->{$l} ) {
-      my %info = FS::Locales->locale_info($l);
-      push @tabs, 
-           $info{'label'},
-           ';locale='.$l;
-      $selected = $info{'label'} if $object->locale eq $l;
-    }
-    else {
-      $display_new = 1; # there is at least one unused locale left
-    }
-  }
-  push @tabs, mt('New'), ';locale=new' if $display_new;
-  $selected = mt('New') if $object->locale eq 'new';
-  $selected ||= mt('Default');
-  (
-    'url_base' => $p.'edit/msg_template.html?msgnum='.$msgnum,
-    'selected' => $selected,
-    'tabs'     => \@tabs
-  );
-}
-
-my $onchange_locale = '';
-
-# Create hints pane
-
-my %substitutions = (
-  'cust_main' => [
-    '$display_custnum'=> 'Customer#',
-    '$agentnum'       => 'Agent#',
-    '$agent_name'     => 'Agent name',
-    '$payby'          => 'Payment method',
-    '$paymask'        => 'Card/account# (masked)',
-    '$payname'        => 'Name on card/bank name',
-    '$paytype'        => 'Account type',
-    '$payip'          => 'IP address used to submit payment info',
-    '$num_ncancelled_pkgs'  => '# of active packages',
-    '$num_cancelled_pkgs'   => '# of cancelled packages',
-    '$num_pkgs'       => '# of packages',
-    '$classname'      => 'Customer class',
-    '$categoryname'   => 'Customer category',
-    '$balance'        => 'Current balance',
-    '$credit_limit'   => 'Credit limit',
-    '$invoicing_list_emailonly' => 'Billing email address',
-    #'$cust_status'    => 'Status (raw internal label)',
-    '$cust_status_label' => 'Status (display label)',
-    '$cust_statuscolor'  => 'Status color code',
-    '$company_name'   => 'Our company name',
-    '$company_address'=> 'Our company address',
-    '$company_phonenum' => 'Our phone number',
-    '$selfservice_server_base_url' => 'Base URL of customer self-service',
-  ],
-  'contact' => [ # duplicate this for shipping
-    '$name'           => 'Company and contact name',
-    '$name_short'     => 'Company or contact name',
-    '$company'        => 'Company name',
-    '$contact'        => 'Contact name (last, first)',
-    '$contact_firstlast'=> 'Contact name (first last)',
-    '$first'          => 'First name',
-    '$last'           => 'Last name',
-    '$address1'       => 'Address line 1',
-    '$address2'       => 'Address line 2',
-    '$city'           => 'City',
-    '$county'         => 'County',
-    '$state'          => 'State',
-    '$zip'            => 'Zip',
-    '$country'        => 'Country',
-    '$daytime'        => 'Day phone',
-    '$night'          => 'Night phone',
-    '$mobile'         => 'Mobile phone',
-    '$fax'            => 'Fax',
-  ],
-  'service' => [
-    '$ship_address1'  => 'Address line 1',
-    '$ship_address2'  => 'Address line 2',
-    '$ship_city'      => 'City',
-    '$ship_county'    => 'County',
-    '$ship_state'     => 'State',
-    '$ship_zip'       => 'Zip',
-    '$ship_country'   => 'Country',
-  ],
-  'cust_bill' => [
-    '$invnum'         => 'Invoice#',
-    '$_date_pretty'   => 'Invoice date',
-    '$due_date'       => 'Invoice due date (timestamp)',
-    '$due_date2str'   => 'Invoice due date (human readable)',
-  ],
-  'cust_pkg' => [
-    '$pkgnum'         => 'Package#',
-    '$pkg'            => 'Package description',
-    '$pkg_label'      => 'Description + comment',
-    '$status'         => 'Status',
-    '$statuscolor'    => 'Status color code',
-    '$start_ymd'      => 'Start date',
-    '$setup_ymd'      => 'Setup date',
-    '$last_bill_ymd'  => 'Last bill date',
-    '$next_bill_ymd'  => 'Next bill date',
-    '$susp_ymd'       => 'Suspended on date',
-    '$cancel_ymd'     => 'Canceled on date',
-    '$adjourn_ymd'    => 'Adjournment date',
-    '$expire_ymd'     => 'Expiration date',
-    '$labels_short'   => 'Service labels',
-    '$location_label' => 'Service location',
-  ],
-  'svc_acct'  => [
-    '$svcnum'         => 'Service#',
-    '$username'       => 'Login name',
-    '$password'       => 'Password',
-    '$domain'         => 'Domain name',
-  ],
-  'svc_domain' => [
-    '$svcnum'         => 'Service#',
-    '$domain'         => 'Domain name',
-    '$registrar'      => 'Registrar name',
-    '$catchall'       => 'Catchall email',
-  ],
-  'svc_phone' => [
-    '$svcnum'         => 'Service#',
-    '$phonenum'       => 'Phone number',
-    '$countrycode'    => 'Country code',
-    '$domain'         => 'Domain name'
-  ],
-  'svc_broadband' => [
-    '$svcnum'         => 'Service#',
-    '$ip_addr'        => 'IP address',
-    '$mac_addr'       => 'MAC address',
-    '$speed_up'       => 'Upstream speed',
-    '$speed_down'     => 'Downstream speed',
-  ],
-  'cust_pay'  => [
-    '$paynum'         => 'Payment#',
-    '$paid'           => 'Amount',
-    '$payby'          => 'Payment method',
-    '$date'           => 'Payment date',
-    '$payinfo'        => 'Card/account# (masked)',
-    '$error'          => 'Decline reason',
-  ],
-);
-
-tie my %sections, 'Tie::IxHash', (
-'contact'   => 'Name and contact info (billing)',
-'service'   => 'Service address',
-'cust_main' => 'Customer status and payment info',
-'cust_pkg'  => 'Package fields',
-'cust_bill' => 'Invoice fields',
-'cust_pay'  => 'Payment fields',
-'svc_acct'  => 'Login service fields',
-'svc_domain'=> 'Domain service fields',
-'svc_phone' => 'Phone service fields',
-'svc_broadband' => 'Broadband service fields',
-);
-
-my $widget = new HTML::Widgets::SelectLayers(
-  'options'   => \%sections,
-  'form_name' => 'dummy',
-  'html_between'=>'</FORM><FONT SIZE=-1>',
-  'selected_layer'=>(keys(%sections))[0],
-  'layer_callback' => sub {
-    my $section = shift;
-    my $html = include('/elements/table-grid.html');
-    my @hints = @{ $substitutions{$section} };
-    while(@hints) {
-      my $key = shift @hints;
-      $html .= qq!\n<TR><TD><A href="javascript:insertHtml('{$key}')">$key</A></TD>!;
-      $html .= "\n<TD>".shift(@hints).'</TD></TR>';
-    }
-    $html .= "\n</TABLE>";
-    return $html;
-  },
-);
-
-my $sidebar = '
-<SCRIPT TYPE="text/javascript">
-function insertHtml(what) {
-  var oEditor = CKEDITOR.instances["body"];
-  oEditor.insertHtml(what);
-};
-
-function areyousure(url, message) {
-  if (confirm(message))
-    window.location.href = url;
-}
-</SCRIPT>
-<TD valign="top"><FORM name="dummy">
-Substitutions: '
-. $widget->html .
-'<P>Click above links to insert substitution code.</P>
-<P>
-Enclose substitutions and other Perl expressions in braces:
-<BR>{ $name } = ExampleCo (Smith, John)
-<BR>{ time2str("%D", time) } = '.time2str("%D", time).'
-</P>';
-$sidebar .= include('/elements/template_image-dialog.html',
-              'callback' => 'insertHtml'
-            );
-$sidebar .= '<P><A HREF="javascript:insertImageDialog()">Insert Uploaded Image</A></P>
-</FONT></TD>
-';
-
-sub html_table_bottom {
-  my $object = shift;
-  $cgi->param('locale') =~ /^(\w+)$/;
-  my $locale = $1;
-  my $html;
-  if ( $locale and $locale ne 'new' ) {
-    # set up a delete link
-    my $msgnum = $object->msgnum;
-    my $url = $p."misc/delete-template_content.html?msgnum=$msgnum;locale=$1";
-    my $link = qq!<A HREF="javascript:areyousure('$url','Really delete this template?')">! .
-      'Delete this template' .
-      '</A>';
-    $html = qq!<TR><TD></TD>
-      <TD STYLE="font-style: italic; font-size: small">$link</TD></TR>!;
-  }
-  $html;
-}
-
+print $cgi->redirect($fsurl."edit/msg_template/$msgclass.html?".$cgi->query_string);
 </%init>
diff --git a/httemplate/edit/msg_template.html b/httemplate/edit/msg_template/email.html
similarity index 97%
copy from httemplate/edit/msg_template.html
copy to httemplate/edit/msg_template/email.html
index df72c5b..dc70ef6 100644
--- a/httemplate/edit/msg_template.html
+++ b/httemplate/edit/msg_template/email.html
@@ -1,4 +1,5 @@
-<& elements/edit.html,
+<& /edit/elements/edit.html,
+     'post_url'         => $fsurl.'edit/process/msg_template.html', 
      'html_init'        => '<TABLE id="outerTable"><TR><TD>',
      'body_etc'         => $body_etc,
      'name_singular'    => 'template',
@@ -54,6 +55,10 @@ if ( $curuser->access_right('Edit global templates')
    )
 {
   push @fields,
+      { field => 'msgclass',
+        type  => 'hidden',
+        value => 'email',
+      },
       { field => 'agentnum',
         type  => 'select-agent',
       },
@@ -176,7 +181,7 @@ sub menubar_opt_callback {
   $selected = mt('New') if $object->locale eq 'new';
   $selected ||= mt('Default');
   (
-    'url_base' => $p.'edit/msg_template.html?msgnum='.$msgnum,
+    'url_base' => $fsurl.'edit/msg_template.html?msgnum='.$msgnum,
     'selected' => $selected,
     'tabs'     => \@tabs
   );
@@ -367,7 +372,7 @@ sub html_table_bottom {
   if ( $locale and $locale ne 'new' ) {
     # set up a delete link
     my $msgnum = $object->msgnum;
-    my $url = $p."misc/delete-template_content.html?msgnum=$msgnum;locale=$1";
+    my $url = $fsurl."misc/delete-template_content.html?msgnum=$msgnum;locale=$1";
     my $link = qq!<A HREF="javascript:areyousure('$url','Really delete this template?')">! .
       'Delete this template' .
       '</A>';
diff --git a/httemplate/edit/msg_template/http.html b/httemplate/edit/msg_template/http.html
new file mode 100644
index 0000000..e82cc0c
--- /dev/null
+++ b/httemplate/edit/msg_template/http.html
@@ -0,0 +1,82 @@
+<& /edit/elements/edit.html,
+    'post_url'         => $fsurl.'edit/process/msg_template.html', 
+    'name_singular'    => 'message interface',
+    'table'            => 'msg_template',
+    'viewall_dir'      => 'browse',
+    'agent_virt'       => 1,
+    'agent_null'       => 1,
+    'agent_null_right' => [ 'View global templates', 'Edit global templates' ],
+
+    'fields'           => [], # callback takes care of this
+    'new_callback'     => $edit_callback,
+    'edit_callback'    => $edit_callback,
+    'error_callback'   => $edit_callback,
+    'labels'           => \%labels,
+    'no_submit'        => $no_submit,
+&>
+<%init>
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right([ 'View templates', 'View global templates',
+                                  'Edit templates', 'Edit global templates',
+                               ]);
+
+my %labels = (
+  'msgnum'      => 'Template', # it's still a template number
+  'agentnum'    => 'Agent',
+  'msgname'     => 'Interface name',
+  'prepare_url' => 'Prepare URL',
+  'send_url'    => 'Send URL',
+  'username'    => 'HTTP username',
+  'password'    => 'HTTP password',
+  'content'     => 'Additional POST content',
+);
+
+my $no_submit = 0;
+
+my $edit_callback = sub {
+  my ($cgi, $msg_template, $fields, $opt) = @_;
+  if ( $curuser->access_right('Edit global templates') 
+     || (    $curuser->access_right('Edit templates')
+          && $msg_template
+          && $msg_template->agentnum
+          && $curuser->agentnums_href->{$msg_template->agentnum}
+        )
+  ) {
+    @$fields = (
+        { field => 'msgclass',
+          type  => 'hidden',
+          value => 'http',
+        },
+        { field => 'agentnum',
+          type  => 'select-agent',
+        },
+        { field => 'msgname',   size=>60, required => 1 },
+        { field => 'prepare_url', size=>60, required => 1 },
+        { field => 'send_url',  size=>60, required => 1 },
+        { field => 'username', size=>20 },
+        { field => 'password', size=>20 },
+        { field => 'content', type => 'textarea' },
+    );
+  } else { #readonly
+
+    $no_submit = 1;
+
+    @$fields = (
+        { field => 'agentnum',
+          type  => 'select-agent',
+          fixed => 1,
+        },
+        { field => 'msgname',   type => 'fixed', },
+        { field => 'prepare_url', type => 'fixed', },
+        { field => 'send_url',  type => 'fixed', },
+        { field => 'username',  type => 'fixed', },
+        { field => 'password',  type => 'fixed', },
+        { field => 'content', type => 'fixed' },
+    );
+
+  }
+};
+
+</%init>
diff --git a/httemplate/edit/process/msg_template.html b/httemplate/edit/process/msg_template.html
index e146adf..d8b125a 100644
--- a/httemplate/edit/process/msg_template.html
+++ b/httemplate/edit/process/msg_template.html
@@ -1,7 +1,7 @@
 <% include( 'elements/process.html',  
               'table'       => 'msg_template',
-              'viewall_dir' => 'browse',
-              #'popup_reload'=> 1,
+              'fields'      => $fields,
+              'viewall_url' => "browse/msg_template/$msgclass.html",
               'debug'       => 0,
               'precheck_callback' => \&precheck_callback,
               'args_callback' => \&args_callback,
@@ -11,9 +11,21 @@
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right(['Edit templates','Edit global templates']);
 
+my $msgclass = 'email';
+if ( $cgi->param('msgclass') =~ /^(\w+)$/ ) {
+  $msgclass = $1;
+}
+
+my $fields = [ fields('msg_template') ];
+my $class = "FS::msg_template::$msgclass";
+eval "use $class;";
+if ( $class->extension_table ) {
+  push @$fields, fields($class->extension_table);
+}
+
 sub precheck_callback {
   my $cgi = shift;
-  # validate some fields
+  # validate locale field (for email-type records)
   $cgi->param('locale') =~ /^(\w*)$/;
   my $locale = $1;
   return mt('Language required') if $locale eq 'new'; # the user didn't choose
diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html
index a5fb15b..55645cf 100644
--- a/httemplate/elements/menu.html
+++ b/httemplate/elements/menu.html
@@ -767,7 +767,7 @@ tie my %config_nms, 'Tie::IxHash',
 ;
 
 tie my %config_misc, 'Tie::IxHash';
-$config_misc{'Message templates'} = [ $fsurl.'browse/msg_template.html', 'Templates for customer notices' ]
+$config_misc{'Message templates'} = [ $fsurl.'browse/msg_template/email.html', 'Templates for customer notices' ]
   if $curuser->access_right(['View templates', 'View global templates',
                              'Edit templates', 'Edit global templates', ]);
 $config_misc{'Advertising sources'} = [ $fsurl.'browse/part_referral.html', 'Where a customer heard about your service.' ]

commit 2d8749c581dce1c2564487b87425b60cbb37a690
Author: Mark Wells <mark at freeside.biz>
Date:   Sat Aug 29 13:37:23 2015 -0700

    #21564, external message services: REST client

diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm
index d7d9f50..827bb98 100644
--- a/FS/FS/msg_template.pm
+++ b/FS/FS/msg_template.pm
@@ -35,6 +35,12 @@ FS::msg_template - Object methods for msg_template records
 
   $error = $record->check;
 
+=head1 NOTE
+
+This uses a table-per-subclass ORM strategy, which is a somewhat cleaner
+version of what we do elsewhere with _option tables. We could easily extract 
+that functionality into a base class, or even into FS::Record itself.
+
 =head1 DESCRIPTION
 
 An FS::msg_template object represents a customer message template.
@@ -81,20 +87,66 @@ points to.  You can ask the object for a copy with the I<hash> method.
 
 sub table { 'msg_template'; }
 
+sub extension_table { ''; } # subclasses don't HAVE to have extensions
+
 sub _rebless {
   my $self = shift;
   my $class = 'FS::msg_template::' . $self->msgclass;
   eval "use $class;";
   bless($self, $class) unless $@;
+
+  # merge in the extension fields
+  if ( $self->msgnum and $self->extension_table ) {
+    my $extension = $self->_extension;
+    if ( $extension ) {
+      $self->{Hash} = { $self->hash, $extension->hash };
+    }
+  }
+
   $self;
 }
 
+# Returns the subclass-specific extension record for this object. For internal
+# use only; everyone else is supposed to think of this as a single record.
+
+sub _extension {
+  my $self = shift;
+  if ( $self->extension_table and $self->msgnum ) {
+    local $FS::Record::nowarn_classload = 1;
+    return qsearchs($self->extension_table, { msgnum => $self->msgnum });
+  }
+  return;
+}
+
 =item insert [ CONTENT ]
 
 Adds this record to the database.  If there is an error, returns the error,
 otherwise returns false.
 
-# inherited
+=cut
+
+sub insert {
+  my $self = shift;
+  $self->_rebless;
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+
+  my $error = $self->SUPER::insert;
+  # calling _extension at this point makes it copy the msgnum, so links work
+  if ( $self->extension_table ) {
+    local $FS::Record::nowarn_classload = 1;
+    my $extension = FS::Record->new($self->extension_table, { $self->hash });
+    $error ||= $extension->insert;
+  }
+
+  if ( $error ) {
+    dbh->rollback if $oldAutoCommit;
+  } else {
+    dbh->commit if $oldAutoCommit;
+  }
+  $error;
+}
 
 =item delete
 
@@ -102,16 +154,56 @@ Delete this record from the database.
 
 =cut
 
-# inherited
+sub delete {
+  my $self = shift;
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+
+  my $error;
+  my $extension = $self->_extension;
+  if ( $extension ) {
+    $error = $extension->delete;
+  }
+
+  $error ||= $self->SUPER::delete;
+
+  if ( $error ) {
+    dbh->rollback if $oldAutoCommit;
+  } else {
+    dbh->commit if $oldAutoCommit;
+  }
+  $error;
+}
 
-=item replace [ OLD_RECORD ] [ CONTENT ]
+=item replace [ OLD_RECORD ]
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
 =cut
 
-# inherited
+sub replace {
+  my $new = shift;
+  my $old = shift || $new->replace_old;
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+
+  my $error = $new->SUPER::replace($old, @_);
+
+  my $extension = $new->_extension;
+  if ( $extension ) {
+    $error ||= $extension->replace;
+  }
+
+  if ( $error ) {
+    dbh->rollback if $oldAutoCommit;
+  } else {
+    dbh->commit if $oldAutoCommit;
+  }
+  $error;
+}
 
 sub replace_check {
   my $self = shift;
diff --git a/FS/FS/msg_template/email.pm b/FS/FS/msg_template/email.pm
index 275dc82..e69183f 100644
--- a/FS/FS/msg_template/email.pm
+++ b/FS/FS/msg_template/email.pm
@@ -448,17 +448,10 @@ sub content {
 
 =cut
 
-=back
-
-=head2 CLASS METHODS
-
-=over 4
-
 =item send_prepared CUST_MSG
 
-Takes the CUST_MSG object and sends it to its recipient. This is a class 
-method because everything needed to send the message is stored in the 
-CUST_MSG already.
+Takes the CUST_MSG object and sends it to its recipient. The "smtpmachine"
+configuration option will be used to find the outgoing mail server.
 
 =cut
 
diff --git a/FS/FS/msg_template/http.pm b/FS/FS/msg_template/http.pm
new file mode 100644
index 0000000..51dfcff
--- /dev/null
+++ b/FS/FS/msg_template/http.pm
@@ -0,0 +1,155 @@
+package FS::msg_template::http;
+use base qw( FS::msg_template );
+
+use strict;
+use vars qw( $DEBUG $conf );
+
+# needed to talk to the external service
+use LWP::UserAgent;
+use HTTP::Request::Common;
+use JSON;
+
+# needed to manage prepared messages
+use FS::cust_msg;
+
+our $DEBUG = 1;
+our $me = '[FS::msg_template::http]';
+
+sub extension_table { 'msg_template_http' }
+
+=head1 NAME
+
+FS::msg_template::http - Send messages via a web service.
+
+=head1 DESCRIPTION
+
+FS::msg_template::http is a message processor in which the message is exported
+to a web service, at both the prepare and send stages.
+
+=head1 METHODS
+
+=cut
+
+sub check {
+  my $self = shift;
+  return 
+       $self->ut_textn('prepare_url')
+    || $self->ut_textn('send_url')
+    || $self->ut_textn('username')
+    || $self->ut_textn('password')
+    || $self->ut_anything('content')
+    || $self->SUPER::check;
+}
+
+sub prepare {
+
+  my( $self, %opt ) = @_;
+
+  my $json = JSON->new->canonical(1);
+
+  my $cust_main = $opt{'cust_main'}; # or die 'cust_main required';
+  my $object = $opt{'object'} or die 'object required';
+
+  my $hashref = $self->prepare_substitutions(%opt);
+
+  my $document = $json->decode( $self->content || '{}' );
+  $document = {
+    'msgname' => $self->msgname,
+    'msgtype' => $opt{'msgtype'},
+    %$document,
+    %$hashref
+  };
+
+  my $request_content = $json->encode($document);
+  warn "$me ".$self->prepare_url."\n" if $DEBUG;
+  warn "$request_content\n\n" if $DEBUG > 1;
+  my $ua = LWP::UserAgent->new;
+  my $request = POST(
+    $self->prepare_url,
+    'Content-Type' => 'application/json',
+    'Content' => $request_content,
+  );
+  if ( $self->username ) {
+    $request->authorization_basic( $self->username, $self->password );
+  }
+  my $response = $ua->request($request);
+  warn "$me received:\n" . $response->as_string . "\n\n" if $DEBUG;
+
+  my $cust_msg = FS::cust_msg->new({
+      'custnum'   => $cust_main->custnum,
+      'msgnum'    => $self->msgnum,
+      '_date'     => time,
+      'msgtype'   => ($opt{'msgtype'} || ''),
+  });
+
+  if ( $response->is_success ) {
+    $cust_msg->set(body => $response->decoded_content);
+    $cust_msg->set(status => 'prepared');
+  } else {
+    $cust_msg->set(status => 'failed');
+    $cust_msg->set(error => $response->decoded_content);
+  }
+
+  $cust_msg;
+}
+
+=item send_prepared CUST_MSG
+
+Takes the CUST_MSG object and sends it to its recipient.
+
+=cut
+
+sub send_prepared {
+  my $self = shift;
+  my $cust_msg = shift or die "cust_msg required";
+  # don't just fail if called as a class method
+  if (!ref $self) {
+    $self = $cust_msg->msg_template;
+  }
+
+  # use cust_msg->header for anything? we _could_...
+  my $request_content = $cust_msg->body;
+
+  warn "$me ".$self->send_url."\n" if $DEBUG;
+  warn "$request_content\n\n" if $DEBUG > 1;
+  my $ua = LWP::UserAgent->new;
+  my $request = POST(
+    $self->send_url,
+    'Content-Type' => 'application/json',
+    'Content' => $request_content,
+  );
+  if ( $self->username ) {
+    $request->authorization_basic( $self->username, $self->password );
+  }
+  my $response = $ua->request($request);
+  warn "$me received:\n" . $response->as_string . "\n\n" if $DEBUG;
+
+  my $error;
+  if ( $response->is_success ) {
+    $cust_msg->set(status => 'sent');
+  } else {
+    $error = $response->decoded_content;
+    $cust_msg->set(error => $error);
+    $cust_msg->set(status => 'failed');
+  }
+
+  if ( $cust_msg->custmsgnum ) {
+    $cust_msg->replace;
+  } else {
+    $cust_msg->insert;
+  }
+
+  $error;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
diff --git a/bin/msg_template_http-demo.pl b/bin/msg_template_http-demo.pl
new file mode 100755
index 0000000..8d184fc
--- /dev/null
+++ b/bin/msg_template_http-demo.pl
@@ -0,0 +1,76 @@
+=head1 NAME
+
+FS::msg_template::http example server.
+
+=head1 DESCRIPTION
+
+This is an incredibly crude Mojo web service for demonstrating how to talk 
+to the HTTP customer messaging interface in Freeside.
+
+It implements an endpoint for the "password reset" messaging case which 
+creates a simple password reset message using some template variables,
+and a "send" endpoint that just delivers the message by sendmail. The 
+configuration to use this as your password reset handler would be:
+
+prepare_url = 'http://localhost:3000/prepare/password_reset'
+send_url =    'http://localhost:3000/send'
+No username, no password, no additional content.
+
+=cut
+
+use Mojolicious::Lite;
+use Mojo::JSON qw(decode_json encode_json);
+use Email::Simple;
+use Email::Simple::Creator;
+use Email::Sender::Simple qw(sendmail);
+
+post '/prepare/password_reset' => sub {
+  my $self = shift;
+
+  my $json_data = $self->req->body;
+  #print STDERR $json_data;
+  my $input = decode_json($json_data);
+  if ( $input->{username} ) {
+    my $output = {
+      'to'      => $input->{invoicing_email},
+      'subject' => "Password reset for $input->{username}",
+      'body'    => "
+To complete your $input->{company_name} password reset, please go to 
+$input->{selfservice_server_base_url}/selfservice.cgi?action=process_forgot_password;session_id=$input->{session_id}
+
+This link will expire in 24 hours.",
+    };
+
+    return $self->render( json => $output );
+
+  } else {
+
+    return $self->render( text => 'Username required', status => 500 );
+
+  }
+};
+
+post '/send' => sub {
+  my $self = shift;
+
+  my $json_data = $self->req->body;
+  my $input = decode_json($json_data);
+  my $email = Email::Simple->create(
+    header => [
+      From    => $ENV{USER}.'@localhost',
+      To      => $input->{to},
+      Subject => $input->{subject},
+    ],
+    body => $input->{body},
+  );
+  local $@;
+  eval { sendmail($email) };
+  if ( $@ ) {
+    return $self->render( text => $@->message, status => 500 );
+  } else {
+    return $self->render( text => '' );
+  }
+};
+
+app->start;
+

commit 179b05af5cc54a0295c65323b0a23b6c9426d6a5
Author: Mark Wells <mark at freeside.biz>
Date:   Sat Aug 29 13:58:58 2015 -0700

    #21564: queueable sending

diff --git a/FS/FS/cust_msg.pm b/FS/FS/cust_msg.pm
index ec2c961..db02680 100644
--- a/FS/FS/cust_msg.pm
+++ b/FS/FS/cust_msg.pm
@@ -205,6 +205,25 @@ sub parts {
 
 =back
 
+=head1 SUBROUTINES
+
+=over 4
+
+=item process_send CUSTMSGNUM
+
+Given a C<cust_msg.custmsgnum> value, sends the message. It must already
+have been prepared (via L<FS::msg_template/prepare>).
+
+=cut
+
+sub process_send {
+  my $custmsgnum = shift;
+  my $cust_msg = FS::cust_msg->by_key($custmsgnum)
+    or die "cust_msg #$custmsgnum not found";
+  my $error = $cust_msg->send;
+  die $error if $error;
+}
+
 =head1 SEE ALSO
 
 L<FS::msg_template>, L<FS::cust_main>, L<FS::Record>.

commit 5a2a2f8a6f95738758b43cdbbfa48dd7830de2d5
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Aug 27 19:18:42 2015 -0700

    #21564, external message services: preview and send messages through the UI

diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 311313a..12211d1 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -6400,6 +6400,7 @@ sub tables_hashref {
         'error',     'varchar', 'NULL',    255, '', '',
         'status',    'varchar',     '',$char_d, '', '',
         'msgtype',   'varchar', 'NULL',     16, '', '',
+        'preview',      'text', 'NULL',     '', '', '',
       ],
       'primary_key'  => 'custmsgnum',
       'unique'       => [ ],
diff --git a/FS/FS/cust_main_Mixin.pm b/FS/FS/cust_main_Mixin.pm
index bdad511..3d05f84 100644
--- a/FS/FS/cust_main_Mixin.pm
+++ b/FS/FS/cust_main_Mixin.pm
@@ -445,6 +445,10 @@ sub email_search_result {
   my $success = 0;
   my %sent_to = ();
 
+  if ( !$msg_template ) {
+    # XXX create on the fly
+  }
+
   #eventually order+limit magic to reduce memory use?
   foreach my $obj ( qsearch($sql_query) ) {
 
@@ -459,36 +463,19 @@ sub email_search_result {
     }
 
     my $cust_main = $obj->cust_main;
-    tie my %message, 'Tie::IxHash';
     if ( !$cust_main ) { 
       next; # unlinked object; nothing else we can do
     }
 
-    if ( $msg_template ) {
-      # Now supports other context objects.
-      %message = $msg_template->prepare(
-        'cust_main' => $cust_main,
-        'object'    => $obj,
-      );
-    }
-    else {
-      my @to = $cust_main->invoicing_list_emailonly;
-      next if !@to;
-
-      %message = (
-        'from'      => $from,
-        'to'        => \@to,
-        'subject'   => $subject,
-        'html_body' => $html_body,
-        'text_body' => $text_body,
-        'custnum'   => $cust_main->custnum,
-      );
-    } #if $msg_template
+    my $cust_msg = $msg_template->prepare(
+      'cust_main' => $cust_main,
+      'object'    => $obj,
+    );
 
     # For non-cust_main searches, we avoid duplicates based on message
-    # body text.  
+    # body text.
     my $unique = $cust_main->custnum;
-    $unique .= sha1($message{'text_body'}) if $class ne 'FS::cust_main';
+    $unique .= sha1($cust_msg->text_body) if $class ne 'FS::cust_main';
     if( $sent_to{$unique} ) {
       # avoid duplicates
       $dups++;
@@ -497,18 +484,20 @@ sub email_search_result {
 
     $sent_to{$unique} = 1;
     
-    $error = send_email( generate_email( %message ) );
+    $error = $cust_msg->send;
 
     if($error) {
       # queue the sending of this message so that the user can see what we
       # tried to do, and retry if desired
+      # (note the cust_msg itself also now has a status of 'failed'; that's 
+      # fine, as it will get its status reset if we retry the job)
       my $queue = new FS::queue {
-        'job'        => 'FS::Misc::process_send_email',
+        'job'        => 'FS::cust_msg::process_send',
         'custnum'    => $cust_main->custnum,
         'status'     => 'failed',
         'statustext' => $error,
       };
-      $queue->insert(%message);
+      $queue->insert($cust_msg->custmsgnum);
       push @retry_jobs, $queue;
     }
     else {
diff --git a/FS/FS/cust_msg.pm b/FS/FS/cust_msg.pm
index 9346327..ec2c961 100644
--- a/FS/FS/cust_msg.pm
+++ b/FS/FS/cust_msg.pm
@@ -47,8 +47,12 @@ from FS::Record.  The following fields are currently supported:
 
 =item body - message body (as a complete MIME document)
 
+=item preview - HTML fragment to show as a preview of the message
+
 =item error - Email::Sender error message (or null for success)
 
+=item status - "prepared", "sent", or "failed"
+
 =back
 
 =head1 METHODS
@@ -137,6 +141,7 @@ sub check {
     || $self->ut_textn('env_to')
     || $self->ut_anything('header')
     || $self->ut_anything('body')
+    || $self->ut_anything('preview')
     || $self->ut_enum('status', \@statuses)
     || $self->ut_textn('error')
     || $self->ut_enum('msgtype', [  '',
@@ -159,8 +164,9 @@ message on error, or an empty string.
 
 sub send {
   my $self = shift;
-  my $msg_template = $self->msg_template
-    or return 'message was created without a template object';
+  # it's still allowed to have cust_msgs without message templates, but only 
+  # for email.
+  my $msg_template = $self->msg_template || 'FS::msg_template::email';
   $msg_template->send_prepared($self);
 }
 
diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm
index 180e9de..d7d9f50 100644
--- a/FS/FS/msg_template.pm
+++ b/FS/FS/msg_template.pm
@@ -10,6 +10,8 @@ use FS::Record qw( qsearch qsearchs );
 use FS::cust_msg;
 use FS::template_content;
 
+use Date::Format qw(time2str);
+
 FS::UID->install_callback( sub { $conf = new FS::Conf; } );
 
 $DEBUG=0;
diff --git a/FS/FS/msg_template/email.pm b/FS/FS/msg_template/email.pm
index 1133faa..275dc82 100644
--- a/FS/FS/msg_template/email.pm
+++ b/FS/FS/msg_template/email.pm
@@ -26,11 +26,12 @@ use FS::Record qw( qsearch qsearchs );
 use FS::template_content;
 use FS::UID qw( dbh );
 
+# needed to manage prepared messages
 use FS::cust_msg;
 
 FS::UID->install_callback( sub { $conf = new FS::Conf; } );
 
-our $DEBUG = 1;
+our $DEBUG = 0;
 our $me = '[FS::msg_template::email]';
 
 =head1 NAME
@@ -362,74 +363,12 @@ sub prepare {
       'error'     => '',
       'status'    => 'prepared',
       'msgtype'   => ($opt{'msgtype'} || ''),
+      'preview'   => $body, # html content only
   });
 
   return $cust_msg;
 }
 
-=item send_prepared CUST_MSG
-
-Takes the CUST_MSG object and sends it to its recipient.
-
-=cut
-
-sub send_prepared {
-  my $self = shift;
-  my $cust_msg = shift or die "cust_msg required";
-
-  my $domain = 'example.com';
-  if ( $cust_msg->env_from =~ /\@([\w\.\-]+)/ ) {
-    $domain = $1;
-  }
-
-  my @to = split(/\s*,\s*/, $cust_msg->env_to);
-
-  my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
-                   'helo' => $domain );
-
-  my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
-  $smtp_opt{'port'} = $port;
-  
-  my $transport;
-  if ( defined($enc) && $enc eq 'starttls' ) {
-    $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
-    $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
-  } else {
-    if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
-      $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);     
-    } 
-    $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
-    $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
-  }
-
-  warn "$me sending message\n" if $DEBUG;
-  my $message = join("\n\n", $cust_msg->header, $cust_msg->body);
-  local $@;
-  eval {
-    sendmail( $message, { transport => $transport,
-                          from      => $cust_msg->env_from,
-                          to        => \@to })
-  };
-  my $error = '';
-  if(ref($@) and $@->isa('Email::Sender::Failure')) {
-    $error = $@->code.' ' if $@->code;
-    $error .= $@->message;
-  }
-  else {
-    $error = $@;
-  }
-
-  $cust_msg->set('error', $error);
-  $cust_msg->set('status', $error ? 'failed' : 'sent');
-  if ( $cust_msg->custmsgnum ) {
-    $cust_msg->replace;
-  } else {
-    $cust_msg->insert;
-  }
-
-  $error;
-}
-
 =item render OPTION => VALUE ...
 
 Fills in the template and renders it to a PDF document.  Returns the 
@@ -491,183 +430,6 @@ my $usage_warning = sub {
   return ['', '', ''];
 };
 
-#my $conf = new FS::Conf;
-
-#return contexts and fill-in values
-# If you add anything, be sure to add a description in 
-# httemplate/edit/msg_template.html.
-sub substitutions {
-  { 'cust_main' => [qw(
-      display_custnum agentnum agent_name
-
-      last first company
-      name name_short contact contact_firstlast
-      address1 address2 city county state zip
-      country
-      daytime night mobile fax
-
-      has_ship_address
-      ship_name ship_name_short ship_contact ship_contact_firstlast
-      ship_address1 ship_address2 ship_city ship_county ship_state ship_zip
-      ship_country
-
-      paymask payname paytype payip
-      num_cancelled_pkgs num_ncancelled_pkgs num_pkgs
-      classname categoryname
-      balance
-      credit_limit
-      invoicing_list_emailonly
-      cust_status ucfirst_cust_status cust_statuscolor cust_status_label
-
-      signupdate dundate
-      packages recurdates
-      ),
-      [ invoicing_email => sub { shift->invoicing_list_emailonly_scalar } ],
-      #compatibility: obsolete ship_ fields - use the non-ship versions
-      map (
-        { my $field = $_;
-          [ "ship_$field"   => sub { shift->$field } ]
-        }
-        qw( last first company daytime night fax )
-      ),
-      # ship_name, ship_name_short, ship_contact, ship_contact_firstlast
-      # still work, though
-      [ expdate           => sub { shift->paydate_epoch } ], #compatibility
-      [ signupdate_ymd    => sub { $ymd->(shift->signupdate) } ],
-      [ dundate_ymd       => sub { $ymd->(shift->dundate) } ],
-      [ paydate_my        => sub { sprintf('%02d/%04d', shift->paydate_monthyear) } ],
-      [ otaker_first      => sub { shift->access_user->first } ],
-      [ otaker_last       => sub { shift->access_user->last } ],
-      [ payby             => sub { FS::payby->shortname(shift->payby) } ],
-      [ company_name      => sub { 
-          $conf->config('company_name', shift->agentnum) 
-        } ],
-      [ company_address   => sub {
-          $conf->config('company_address', shift->agentnum)
-        } ],
-      [ company_phonenum  => sub {
-          $conf->config('company_phonenum', shift->agentnum)
-        } ],
-      [ selfservice_server_base_url => sub { 
-          $conf->config('selfservice_server-base_url') #, shift->agentnum) 
-        } ],
-    ],
-    # next_bill_date
-    'cust_pkg'  => [qw( 
-      pkgnum pkg_label pkg_label_long
-      location_label
-      status statuscolor
-    
-      start_date setup bill last_bill 
-      adjourn susp expire 
-      labels_short
-      ),
-      [ pkg               => sub { shift->part_pkg->pkg } ],
-      [ pkg_category      => sub { shift->part_pkg->categoryname } ],
-      [ pkg_class         => sub { shift->part_pkg->classname } ],
-      [ cancel            => sub { shift->getfield('cancel') } ], # grrr...
-      [ start_ymd         => sub { $ymd->(shift->getfield('start_date')) } ],
-      [ setup_ymd         => sub { $ymd->(shift->getfield('setup')) } ],
-      [ next_bill_ymd     => sub { $ymd->(shift->getfield('bill')) } ],
-      [ last_bill_ymd     => sub { $ymd->(shift->getfield('last_bill')) } ],
-      [ adjourn_ymd       => sub { $ymd->(shift->getfield('adjourn')) } ],
-      [ susp_ymd          => sub { $ymd->(shift->getfield('susp')) } ],
-      [ expire_ymd        => sub { $ymd->(shift->getfield('expire')) } ],
-      [ cancel_ymd        => sub { $ymd->(shift->getfield('cancel')) } ],
-
-      # not necessarily correct for non-flat packages
-      [ setup_fee         => sub { shift->part_pkg->option('setup_fee') } ],
-      [ recur_fee         => sub { shift->part_pkg->option('recur_fee') } ],
-
-      [ freq_pretty       => sub { shift->part_pkg->freq_pretty } ],
-
-    ],
-    'cust_bill' => [qw(
-      invnum
-      _date
-      _date_pretty
-      due_date
-    ),
-      [ due_date2str      => sub { shift->due_date2str('short') } ],
-    ],
-    #XXX not really thinking about cust_bill substitutions quite yet
-    
-    # for welcome and limit warning messages
-    'svc_acct' => [qw(
-      svcnum
-      username
-      domain
-      ),
-      [ password          => sub { shift->getfield('_password') } ],
-      [ column            => sub { &$usage_warning(shift)->[0] } ],
-      [ amount            => sub { &$usage_warning(shift)->[1] } ],
-      [ threshold         => sub { &$usage_warning(shift)->[2] } ],
-    ],
-    'svc_domain' => [qw(
-      svcnum
-      domain
-      ),
-      [ registrar         => sub {
-          my $registrar = qsearchs('registrar', 
-            { registrarnum => shift->registrarnum} );
-          $registrar ? $registrar->registrarname : ''
-        }
-      ],
-      [ catchall          => sub { 
-          my $svc_acct = qsearchs('svc_acct', { svcnum => shift->catchall });
-          $svc_acct ? $svc_acct->email : ''
-        }
-      ],
-    ],
-    'svc_phone' => [qw(
-      svcnum
-      phonenum
-      countrycode
-      domain
-      )
-    ],
-    'svc_broadband' => [qw(
-      svcnum
-      speed_up
-      speed_down
-      ip_addr
-      mac_addr
-      )
-    ],
-    # for payment receipts
-    'cust_pay' => [qw(
-      paynum
-      _date
-      ),
-      [ paid              => sub { sprintf("%.2f", shift->paid) } ],
-      # overrides the one in cust_main in cases where a cust_pay is passed
-      [ payby             => sub { FS::payby->shortname(shift->payby) } ],
-      [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
-      [ payinfo           => sub { 
-          my $cust_pay = shift;
-          ($cust_pay->payby eq 'CARD' || $cust_pay->payby eq 'CHEK') ?
-            $cust_pay->paymask : $cust_pay->decrypt($cust_pay->payinfo)
-        } ],
-    ],
-    # for payment decline messages
-    # try to support all cust_pay fields
-    # 'error' is a special case, it contains the raw error from the gateway
-    'cust_pay_pending' => [qw(
-      _date
-      error
-      ),
-      [ paid              => sub { sprintf("%.2f", shift->paid) } ],
-      [ payby             => sub { FS::payby->shortname(shift->payby) } ],
-      [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
-      [ payinfo           => sub {
-          my $pending = shift;
-          ($pending->payby eq 'CARD' || $pending->payby eq 'CHEK') ?
-            $pending->paymask : $pending->decrypt($pending->payinfo)
-        } ],
-    ],
-  };
-}
-
 =item content LOCALE
 
 Returns the L<FS::template_content> object appropriate to LOCALE, if there 
@@ -684,168 +446,84 @@ sub content {
             { 'msgnum' => $self->msgnum, 'locale' => '' });
 }
 
-=item agent
-
-Returns the L<FS::agent> object for this template.
-
 =cut
 
-sub _upgrade_data {
-  my ($self, %opts) = @_;
+=back
 
-  ###
-  # First move any historical templates in config to real message templates
-  ###
+=head2 CLASS METHODS
 
-  my @fixes = (
-    [ 'alerter_msgnum',  'alerter_template',   '',               '', '' ],
-    [ 'cancel_msgnum',   'cancelmessage',      'cancelsubject',  '', '' ],
-    [ 'decline_msgnum',  'declinetemplate',    '',               '', '' ],
-    [ 'impending_recur_msgnum', 'impending_recur_template', '',  '', 'impending_recur_bcc' ],
-    [ 'payment_receipt_msgnum', 'payment_receipt_email', '',     '', '' ],
-    [ 'welcome_msgnum',  'welcome_email',      'welcome_email-subject', 'welcome_email-from', '' ],
-    [ 'warning_msgnum',  'warning_email',      'warning_email-subject', 'warning_email-from', '' ],
-  );
- 
-  my @agentnums = ('', map {$_->agentnum} qsearch('agent', {}));
-  foreach my $agentnum (@agentnums) {
-    foreach (@fixes) {
-      my ($newname, $oldname, $subject, $from, $bcc) = @$_;
-      if ($conf->exists($oldname, $agentnum)) {
-        my $new = new FS::msg_template({
-          'msgname'   => $oldname,
-          'agentnum'  => $agentnum,
-          'from_addr' => ($from && $conf->config($from, $agentnum)) || '',
-          'bcc_addr'  => ($bcc && $conf->config($from, $agentnum)) || '',
-          'subject'   => ($subject && $conf->config($subject, $agentnum)) || '',
-          'mime_type' => 'text/html',
-          'body'      => join('<BR>',$conf->config($oldname, $agentnum)),
-        });
-        my $error = $new->insert;
-        die $error if $error;
-        $conf->set($newname, $new->msgnum, $agentnum);
-        $conf->delete($oldname, $agentnum);
-        $conf->delete($from, $agentnum) if $from;
-        $conf->delete($subject, $agentnum) if $subject;
-      }
-    }
+=over 4
 
-    if ( $conf->exists('alert_expiration', $agentnum) ) {
-      my $msgnum = $conf->exists('alerter_msgnum', $agentnum);
-      my $template = FS::msg_template->by_key($msgnum) if $msgnum;
-      if (!$template) {
-        warn "template for alerter_msgnum $msgnum not found\n";
-        next;
-      }
-      # this is now a set of billing events
-      foreach my $days (30, 15, 5) {
-        my $event = FS::part_event->new({
-            'agentnum'    => $agentnum,
-            'event'       => "Card expiration warning - $days days",
-            'eventtable'  => 'cust_main',
-            'check_freq'  => '1d',
-            'action'      => 'notice',
-            'disabled'    => 'Y', #initialize first
-        });
-        my $error = $event->insert( 'msgnum' => $msgnum );
-        if ($error) {
-          warn "error creating expiration alert event:\n$error\n\n";
-          next;
-        }
-        # make it work like before:
-        # only send each warning once before the card expires,
-        # only warn active customers,
-        # only warn customers with CARD/DCRD,
-        # only warn customers who get email invoices
-        my %conds = (
-          'once_every'          => { 'run_delay' => '30d' },
-          'cust_paydate_within' => { 'within' => $days.'d' },
-          'cust_status'         => { 'status' => { 'active' => 1 } },
-          'payby'               => { 'payby'  => { 'CARD' => 1,
-                                                   'DCRD' => 1, }
-                                   },
-          'message_email'       => {},
-        );
-        foreach (keys %conds) {
-          my $condition = FS::part_event_condition->new({
-              'conditionname' => $_,
-              'eventpart'     => $event->eventpart,
-          });
-          $error = $condition->insert( %{ $conds{$_} });
-          if ( $error ) {
-            warn "error creating expiration alert event:\n$error\n\n";
-            next;
-          }
-        }
-        $error = $event->initialize;
-        if ( $error ) {
-          warn "expiration alert event was created, but not initialized:\n$error\n\n";
-        }
-      } # foreach $days
-      $conf->delete('alerter_msgnum', $agentnum);
-      $conf->delete('alert_expiration', $agentnum);
-
-    } # if alerter_msgnum
+=item send_prepared CUST_MSG
 
-  }
+Takes the CUST_MSG object and sends it to its recipient. This is a class 
+method because everything needed to send the message is stored in the 
+CUST_MSG already.
 
-  ###
-  # Move subject and body from msg_template to template_content
-  ###
+=cut
 
-  foreach my $msg_template ( qsearch('msg_template', {}) ) {
-    if ( $msg_template->subject || $msg_template->body ) {
-      # create new default content
-      my %content;
-      $content{subject} = $msg_template->subject;
-      $msg_template->set('subject', '');
-
-      # work around obscure Pg/DBD bug
-      # https://rt.cpan.org/Public/Bug/Display.html?id=60200
-      # (though the right fix is to upgrade DBD)
-      my $body = $msg_template->body;
-      if ( $body =~ /^x([0-9a-f]+)$/ ) {
-        # there should be no real message templates that look like that
-        warn "converting template body to TEXT\n";
-        $body = pack('H*', $1);
-      }
-      $content{body} = $body;
-      $msg_template->set('body', '');
+sub send_prepared {
+  my $self = shift;
+  my $cust_msg = shift or die "cust_msg required";
 
-      my $error = $msg_template->replace(%content);
-      die $error if $error;
-    }
+  my $domain = 'example.com';
+  if ( $cust_msg->env_from =~ /\@([\w\.\-]+)/ ) {
+    $domain = $1;
   }
 
-  ###
-  # Add new-style default templates if missing
-  ###
-  $self->_populate_initial_data;
-
-}
+  my @to = split(/\s*,\s*/, $cust_msg->env_to);
 
-sub _populate_initial_data { #class method
-  #my($class, %opts) = @_;
-  #my $class = shift;
+  my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
+                   'helo' => $domain );
 
-  eval "use FS::msg_template::InitialData;";
-  die $@ if $@;
+  my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
+  $smtp_opt{'port'} = $port;
+  
+  my $transport;
+  if ( defined($enc) && $enc eq 'starttls' ) {
+    $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
+    $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
+  } else {
+    if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
+      $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);     
+    } 
+    $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
+    $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
+  }
 
-  my $initial_data = FS::msg_template::InitialData->_initial_data;
+  warn "$me sending message\n" if $DEBUG;
+  my $message = join("\n\n", $cust_msg->header, $cust_msg->body);
+  local $@;
+  eval {
+    sendmail( $message, { transport => $transport,
+                          from      => $cust_msg->env_from,
+                          to        => \@to })
+  };
+  my $error = '';
+  if(ref($@) and $@->isa('Email::Sender::Failure')) {
+    $error = $@->code.' ' if $@->code;
+    $error .= $@->message;
+  }
+  else {
+    $error = $@;
+  }
 
-  foreach my $hash ( @$initial_data ) {
+  $cust_msg->set('error', $error);
+  $cust_msg->set('status', $error ? 'failed' : 'sent');
+  if ( $cust_msg->custmsgnum ) {
+    $cust_msg->replace;
+  } else {
+    $cust_msg->insert;
+  }
 
-    next if $hash->{_conf} && $conf->config( $hash->{_conf} );
+  $error;
+}
 
-    my $msg_template = new FS::msg_template($hash);
-    my $error = $msg_template->insert( @{ $hash->{_insert_args} || [] } );
-    die $error if $error;
+=back
 
-    $conf->set( $hash->{_conf}, $msg_template->msgnum ) if $hash->{_conf};
-  
-  }
+=cut
 
-}
+# internal use only
 
 sub eviscerate {
   # Every bit as pleasant as it sounds.
@@ -897,8 +575,6 @@ sub eviscerate {
   (\@outside, \@inside);
 }
 
-=back
-
 =head1 BUGS
 
 =head1 SEE ALSO
diff --git a/httemplate/misc/email-customers.html b/httemplate/misc/email-customers.html
index 8ac44af..bffd0cf 100644
--- a/httemplate/misc/email-customers.html
+++ b/httemplate/misc/email-customers.html
@@ -1,10 +1,11 @@
 <%doc>
 
-Allows emailing one or more customers, based on a search for customers.  Search can
-be specified either through cust_main fields as cgi params, or through a base64 encoded
-frozen hash in the 'search' cgi param.  Form allows selecting an existing msg_template,
-or creating a custom message, and shows a preview of the message before sending.
-If linked to as a popup, include the cgi parameter 'popup' for proper header handling.
+Allows emailing one or more customers, based on a search for customers.
+Search can be specified either through cust_main fields as cgi params, or
+through a base64 encoded frozen hash in the 'search' cgi param.  Form allows
+selecting an existing msg_template, or creating a custom message, and shows a
+preview of the message before sending.  If linked to as a popup, include the
+cgi parameter 'popup' for proper header handling.
 
 This may also be used as an element in other pages, enabling you to provide an
 alternate initial form while using this for search freezing/thawing and 
@@ -21,12 +22,13 @@ title - the title of the page
 no_search_fields - arrayref of additional fields that are not search parameters
 
 alternate_form - subroutine that returns alternate html for the initial form,
-replaces msgnum/from/subject/html_body/action inputs and submit button,
-not used if an action is specified
+replaces msgnum/from/subject/html_body/action inputs and submit button, not
+used if an action is specified
 
-post_search_hook - sub hook for additional processing after search has been processed from cgi,
-gets passed options 'conf' and 'search' (a reference to the unfrozen %search hash),
-should be used to set msgnum or from/subject/html_body cgi params
+post_search_hook - sub hook for additional processing after search has been
+processed from cgi, gets passed options 'conf' and 'search' (a reference to
+the unfrozen %search hash), should be used to set msgnum or
+from/subject/html_body cgi params
 
 </%doc>
 % if ($popup) {
@@ -288,8 +290,13 @@ if ( $cgi->param('action') eq 'preview' ) {
       'cust_main' => $cust,
       'object' => $object,
     );
-    my %message = $msg_template->prepare(%msgopts);
-    ($from, $subject, $html_body) = @message{'from', 'subject', 'html_body'};
+
+    my $cust_msg = $msg_template->prepare(%msgopts);
+    $from = $cust_msg->env_from;
+    $html_body = $cust_msg->preview;
+    if ( $cust_msg->header =~ /^subject: (.*)/mi ) {
+      $subject = $1;
+    }
   }
 }
 

commit 11c81c66b62ac176c167583f7b68ed80bd4239c9
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Aug 27 14:46:31 2015 -0700

    external message services: core refactoring of msg_template

diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index a799cee..311313a 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -6320,8 +6320,11 @@ sub tables_hashref {
         'mime_type', 'varchar',     '', $char_d, '', '',
         'body',         'blob', 'NULL',      '', '', '',
         'disabled',     'char', 'NULL',       1, '', '', 
+          # migrate these to msg_template_email
         'from_addr', 'varchar', 'NULL',     255, '', '',
         'bcc_addr',  'varchar', 'NULL',     255, '', '',
+          # change to not null on v5
+        'msgclass',  'varchar', 'NULL',      16, '', '',
       ],
       'primary_key'  => 'msgnum',
       'unique'       => [ ],
@@ -6333,6 +6336,26 @@ sub tables_hashref {
                         ],
     },
 
+    'msg_template_http' => {
+      'columns' => [
+        'num',          'serial',     '',      '', '', '',
+        'msgnum',          'int',     '',      '', '', '',
+        'prepare_url', 'varchar', 'NULL',     255, '', '',
+        'send_url',    'varchar', 'NULL',     255, '', '',
+        'username',    'varchar', 'NULL', $char_d, '', '',
+        'password',    'varchar', 'NULL', $char_d, '', '',
+        'content',        'text', 'NULL',      '', '', '',
+      ],
+      'primary_key'  => 'num',
+      'unique'       => [ [ 'msgnum' ], ],
+      'index'        => [ ],
+      'foreign_keys' => [
+                          { columns    => [ 'msgnum' ],
+                            table      => 'msg_template',
+                          },
+                        ],
+    },
+
     'template_content' => {
       'columns' => [
         'contentnum', 'serial',     '',      '', '', '',
diff --git a/FS/FS/cust_msg.pm b/FS/FS/cust_msg.pm
index 72f64b9..9346327 100644
--- a/FS/FS/cust_msg.pm
+++ b/FS/FS/cust_msg.pm
@@ -45,7 +45,7 @@ from FS::Record.  The following fields are currently supported:
 
 =item header - message header
 
-=item body - message body
+=item body - message body (as a complete MIME document)
 
 =item error - Email::Sender error message (or null for success)
 
@@ -150,10 +150,27 @@ sub check {
   $self->SUPER::check;
 }
 
+=item send
+
+Sends the message through its parent L<FS::msg_template>. Returns an error
+message on error, or an empty string.
+
+=cut
+
+sub send {
+  my $self = shift;
+  my $msg_template = $self->msg_template
+    or return 'message was created without a template object';
+  $msg_template->send_prepared($self);
+}
+
 =item entity
 
 Returns the complete message as a L<MIME::Entity>.
 
+XXX this only works if the message in fact contains a MIME entity. Messages
+created by external APIs may not look like that.
+
 =item parts
 
 Returns a list of the MIME parts contained in the message, as L<MIME::Entity>
diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm
index c52b633..180e9de 100644
--- a/FS/FS/msg_template.pm
+++ b/FS/FS/msg_template.pm
@@ -4,22 +4,9 @@ use base qw( FS::Record );
 use strict;
 use vars qw( $DEBUG $conf );
 
-use Date::Format qw( time2str );
-use File::Temp;
-use IPC::Run qw(run);
-use Text::Template;
-
-use HTML::Entities qw( decode_entities encode_entities ) ;
-use HTML::FormatText;
-use HTML::TreeBuilder;
-use Encode;
-
-use FS::Misc qw( generate_email send_email do_print );
 use FS::Conf;
 use FS::Record qw( qsearch qsearchs );
-use FS::UID qw( dbh );
 
-use FS::cust_main;
 use FS::cust_msg;
 use FS::template_content;
 
@@ -59,6 +46,9 @@ supported:
 =item msgname - Name of the template.  This will appear in the user interface;
 if it needs to be localized for some users, add it to the message catalog.
 
+=item msgclass - The L<FS::msg_template> subclass that this should belong to.
+Defaults to 'email'.
+
 =item agentnum - Agent associated with this template.  Can be NULL for a 
 global template.
 
@@ -66,6 +56,8 @@ global template.
 
 =item from_addr - Source email address.
 
+=item bcc_addr - Bcc all mail to this address.
+
 =item disabled - disabled ('Y' or NULL).
 
 =back
@@ -87,41 +79,20 @@ points to.  You can ask the object for a copy with the I<hash> method.
 
 sub table { 'msg_template'; }
 
+sub _rebless {
+  my $self = shift;
+  my $class = 'FS::msg_template::' . $self->msgclass;
+  eval "use $class;";
+  bless($self, $class) unless $@;
+  $self;
+}
+
 =item insert [ CONTENT ]
 
 Adds this record to the database.  If there is an error, returns the error,
 otherwise returns false.
 
-A default (no locale) L<FS::template_content> object will be created.  CONTENT 
-is an optional hash containing 'subject' and 'body' for this object.
-
-=cut
-
-sub insert {
-  my $self = shift;
-  my %content = @_;
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  my $error = $self->SUPER::insert;
-  if ( !$error ) {
-    $content{'msgnum'} = $self->msgnum;
-    $content{'subject'} ||= '';
-    $content{'body'} ||= '';
-    my $template_content = new FS::template_content (\%content);
-    $error = $template_content->insert;
-  }
-
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
-  $dbh->commit if $oldAutoCommit;
-  return;
-}
+# inherited
 
 =item delete
 
@@ -129,61 +100,31 @@ Delete this record from the database.
 
 =cut
 
-# the delete method can be inherited from FS::Record
+# inherited
 
 =item replace [ OLD_RECORD ] [ CONTENT ]
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
-CONTENT is an optional hash containing 'subject', 'body', and 'locale'.  If 
-supplied, an L<FS::template_content> object will be created (or modified, if 
-one already exists for this locale).
-
 =cut
 
-sub replace {
+# inherited
+
+sub replace_check {
   my $self = shift;
-  my $old = ( ref($_[0]) and $_[0]->isa('FS::Record') ) 
-              ? shift
-              : $self->replace_old;
-  my %content = @_;
-  
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  my $error = $self->SUPER::replace($old);
-
-  if ( !$error and %content ) {
-    $content{'locale'} ||= '';
-    my $new_content = qsearchs('template_content', {
-                        'msgnum' => $self->msgnum,
-                        'locale' => $content{'locale'},
-                      } );
-    if ( $new_content ) {
-      $new_content->subject($content{'subject'});
-      $new_content->body($content{'body'});
-      $error = $new_content->replace;
-    }
-    else {
-      $content{'msgnum'} = $self->msgnum;
-      $new_content = new FS::template_content \%content;
-      $error = $new_content->insert;
+  my $old = $self->replace_old;
+  # don't allow changing msgclass, except null to not-null (for upgrade)
+  if ( $old->msgclass ) {
+    if ( !$self->msgclass ) {
+      $self->set('msgclass', $old->msgclass);
+    } else {
+      return "Can't change message template class from ".$old->msgclass.
+             " to ".$self->msgclass.".";
     }
   }
-
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
-  warn "committing FS::msg_template->replace\n" if $DEBUG and $oldAutoCommit;
-  $dbh->commit if $oldAutoCommit;
-  return;
+  '';
 }
-    
-
 
 =item check
 
@@ -206,6 +147,10 @@ sub check {
     || $self->ut_textn('mime_type')
     || $self->ut_enum('disabled', [ '', 'Y' ] )
     || $self->ut_textn('from_addr')
+    || $self->ut_textn('bcc_addr')
+    # fine for now, but change this to some kind of dynamic check if we
+    # ever have more than two msgclasses
+    || $self->ut_enum('msgclass', [ qw(email http) ]),
   ;
   return $error if $error;
 
@@ -214,25 +159,10 @@ sub check {
   $self->SUPER::check;
 }
 
-=item content_locales
-
-Returns a hashref of the L<FS::template_content> objects attached to 
-this template, with the locale as key.
-
-=cut
-
-sub content_locales {
-  my $self = shift;
-  return $self->{'_content_locales'} ||= +{
-    map { $_->locale , $_ } 
-    qsearch('template_content', { 'msgnum' => $self->msgnum })
-  };
-}
-
 =item prepare OPTION => VALUE
 
-Fills in the template and returns a hash of the 'from' address, 'to' 
-addresses, subject line, and body.
+Fills in the template and returns an L<FS::cust_msg> object, containing the
+message to be sent.  This method must be provided by the subclass.
 
 Options are passed as a list of name/value pairs:
 
@@ -276,18 +206,23 @@ A hash reference of additional substitutions
 =cut
 
 sub prepare {
+  die "unimplemented";
+}
+
+=item prepare_substitutions OPTION => VALUE ...
+
+Takes the same arguments as L</prepare>, and returns a hashref of the 
+substitution variables.
+
+=cut
+
+sub prepare_substitutions {
   my( $self, %opt ) = @_;
 
   my $cust_main = $opt{'cust_main'}; # or die 'cust_main required';
   my $object = $opt{'object'} or die 'object required';
 
-  # localization
-  my $locale = $cust_main && $cust_main->locale || '';
-  warn "no locale for cust#".$cust_main->custnum."; using default content\n"
-    if $DEBUG and $cust_main && !$locale;
-  my $content = $self->content($locale);
-
-  warn "preparing template '".$self->msgname."\n"
+  warn "preparing substitutions for '".$self->msgname."'\n"
     if $DEBUG;
 
   my $subs = $self->substitutions;
@@ -340,110 +275,19 @@ sub prepare {
     $hash{$_} = $opt{substitutions}->{$_} foreach keys %{$opt{substitutions}};
   }
 
-  $_ = encode_entities($_ || '') foreach values(%hash);
-
-  ###
-  # clean up template
-  ###
-  my $subject_tmpl = new Text::Template (
-    TYPE   => 'STRING',
-    SOURCE => $content->subject,
-  );
-  my $subject = $subject_tmpl->fill_in( HASH => \%hash );
-
-  my $body = $content->body;
-  my ($skin, $guts) = eviscerate($body);
-  @$guts = map { 
-    $_ = decode_entities($_); # turn all punctuation back into itself
-    s/\r//gs;           # remove \r's
-    s/<br[^>]*>/\n/gsi; # and <br /> tags
-    s/<p>/\n/gsi;       # and <p>
-    s/<\/p>//gsi;       # and </p>
-    s/\240/ /gs;        # and  
-    $_
-  } @$guts;
-  
-  $body = '{ use Date::Format qw(time2str); "" }';
-  while(@$skin || @$guts) {
-    $body .= shift(@$skin) || '';
-    $body .= shift(@$guts) || '';
-  }
-
-  ###
-  # fill-in
-  ###
-
-  my $body_tmpl = new Text::Template (
-    TYPE          => 'STRING',
-    SOURCE        => $body,
-  );
-
-  $body = $body_tmpl->fill_in( HASH => \%hash );
-
-  ###
-  # and email
-  ###
-
-  my @to;
-  if ( exists($opt{'to'}) ) {
-    @to = split(/\s*,\s*/, $opt{'to'});
-  } elsif ( $cust_main ) {
-    @to = $cust_main->invoicing_list_emailonly;
-  } else {
-    die 'no To: address or cust_main object specified';
-  }
-
-  my $from_addr = $self->from_addr;
-
-  if ( !$from_addr ) {
-
-    my $agentnum = $cust_main ? $cust_main->agentnum : '';
-
-    if ( $opt{'from_config'} ) {
-      $from_addr = $conf->config($opt{'from_config'}, $agentnum);
-    }
-    $from_addr ||= $conf->invoice_from_full($agentnum);
-  }
-#  my @cust_msg = ();
-#  if ( $conf->exists('log_sent_mail') and !$opt{'preview'} ) {
-#    my $cust_msg = FS::cust_msg->new({
-#        'custnum' => $cust_main->custnum,
-#        'msgnum'  => $self->msgnum,
-#        'status'  => 'prepared',
-#      });
-#    $cust_msg->insert;
-#    @cust_msg = ('cust_msg' => $cust_msg);
-#  }
-
-  my $text_body = encode('UTF-8',
-                  HTML::FormatText->new(leftmargin => 0, rightmargin => 70)
-                      ->format( HTML::TreeBuilder->new_from_content($body) )
-                  );
-  (
-    'custnum'   => ( $cust_main ? $cust_main->custnum : ''),
-    'msgnum'    => $self->msgnum,
-    'from'      => $from_addr,
-    'to'        => \@to,
-    'bcc'       => $self->bcc_addr || undef,
-    'subject'   => $subject,
-    'html_body' => $body,
-    'text_body' => $text_body
-  );
-
+  return \%hash;
 }
 
-=item send OPTION => VALUE
+=item send OPTION => VALUE ...
 
-Fills in the template and sends it to the customer.  Options are as for 
-'prepare'.
+Creates a message with L</prepare> (taking all the same options) and sends it.
 
 =cut
 
-# broken out from prepare() in case we want to queue the sending,
-# preview it, etc.
 sub send {
   my $self = shift;
-  send_email(generate_email($self->prepare(@_)));
+  my $cust_msg = $self->prepare(@_);
+  $self->send_prepared($cust_msg);
 }
 
 =item render OPTION => VALUE ...
@@ -455,6 +299,9 @@ Options are as for 'prepare', but 'from' and 'to' are meaningless.
 
 =cut
 
+# XXX not sure where this ends up post-refactoring--a separate template
+# class? it doesn't use the same rendering OR output machinery as ::email
+
 # will also have options to set paper size, margins, etc.
 
 sub render {
@@ -507,8 +354,6 @@ my $usage_warning = sub {
   return ['', '', ''];
 };
 
-#my $conf = new FS::Conf;
-
 #return contexts and fill-in values
 # If you add anything, be sure to add a description in 
 # httemplate/edit/msg_template.html.
@@ -686,19 +531,11 @@ sub substitutions {
 
 =item content LOCALE
 
-Returns the L<FS::template_content> object appropriate to LOCALE, if there 
-is one.  If not, returns the one with a NULL locale.
+Stub, returns nothing.
 
 =cut
 
-sub content {
-  my $self = shift;
-  my $locale = shift;
-  qsearchs('template_content', 
-            { 'msgnum' => $self->msgnum, 'locale' => $locale }) || 
-  qsearchs('template_content',
-            { 'msgnum' => $self->msgnum, 'locale' => '' });
-}
+sub content {}
 
 =item agent
 
@@ -827,10 +664,16 @@ sub _upgrade_data {
       }
       $content{body} = $body;
       $msg_template->set('body', '');
-
       my $error = $msg_template->replace(%content);
       die $error if $error;
     }
+
+    if ( !$msg_template->msgclass ) {
+      # set default message class
+      $msg_template->set('msgclass', 'email');
+      my $error = $msg_template->replace;
+      die $error if $error;
+    }
   }
 
   ###
@@ -863,56 +706,6 @@ sub _populate_initial_data { #class method
 
 }
 
-sub eviscerate {
-  # Every bit as pleasant as it sounds.
-  #
-  # We do this because Text::Template::Preprocess doesn't
-  # actually work.  It runs the entire template through 
-  # the preprocessor, instead of the code segments.  Which 
-  # is a shame, because Text::Template already contains
-  # the code to do this operation.
-  my $body = shift;
-  my (@outside, @inside);
-  my $depth = 0;
-  my $chunk = '';
-  while($body || $chunk) {
-    my ($first, $delim, $rest);
-    # put all leading non-delimiters into $first
-    ($first, $rest) =
-        ($body =~ /^((?:\\[{}]|[^{}])*)(.*)$/s);
-    $chunk .= $first;
-    # put a leading delimiter into $delim if there is one
-    ($delim, $rest) =
-      ($rest =~ /^([{}]?)(.*)$/s);
-
-    if( $delim eq '{' ) {
-      $chunk .= '{';
-      if( $depth == 0 ) {
-        push @outside, $chunk;
-        $chunk = '';
-      }
-      $depth++;
-    }
-    elsif( $delim eq '}' ) {
-      $depth--;
-      if( $depth == 0 ) {
-        push @inside, $chunk;
-        $chunk = '';
-      }
-      $chunk .= '}';
-    }
-    else {
-      # no more delimiters
-      if( $depth == 0 ) {
-        push @outside, $chunk . $rest;
-      } # else ? something wrong
-      last;
-    }
-    $body = $rest;
-  }
-  (\@outside, \@inside);
-}
-
 =back
 
 =head1 BUGS
diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template/email.pm
similarity index 81%
copy from FS/FS/msg_template.pm
copy to FS/FS/msg_template/email.pm
index c52b633..1133faa 100644
--- a/FS/FS/msg_template.pm
+++ b/FS/FS/msg_template/email.pm
@@ -1,9 +1,10 @@
-package FS::msg_template;
-use base qw( FS::Record );
+package FS::msg_template::email;
+use base qw( FS::msg_template );
 
 use strict;
 use vars qw( $DEBUG $conf );
 
+# stuff needed for template generation
 use Date::Format qw( time2str );
 use File::Temp;
 use IPC::Run qw(run);
@@ -14,79 +15,41 @@ use HTML::FormatText;
 use HTML::TreeBuilder;
 use Encode;
 
-use FS::Misc qw( generate_email send_email do_print );
+# needed to send email
+use FS::Misc qw( generate_email );
 use FS::Conf;
+use Email::Sender::Simple qw( sendmail );
+
 use FS::Record qw( qsearch qsearchs );
+
+# needed to manage template_content objects
+use FS::template_content;
 use FS::UID qw( dbh );
 
-use FS::cust_main;
 use FS::cust_msg;
-use FS::template_content;
 
 FS::UID->install_callback( sub { $conf = new FS::Conf; } );
 
-$DEBUG=0;
+our $DEBUG = 1;
+our $me = '[FS::msg_template::email]';
 
 =head1 NAME
 
-FS::msg_template - Object methods for msg_template records
-
-=head1 SYNOPSIS
-
-  use FS::msg_template;
-
-  $record = new FS::msg_template \%hash;
-  $record = new FS::msg_template { 'column' => 'value' };
-
-  $error = $record->insert;
-
-  $error = $new_record->replace($old_record);
-
-  $error = $record->delete;
-
-  $error = $record->check;
+FS::msg_template::email - Construct email notices with Text::Template.
 
 =head1 DESCRIPTION
 
-An FS::msg_template object represents a customer message template.
-FS::msg_template inherits from FS::Record.  The following fields are currently
-supported:
-
-=over 4
-
-=item msgnum - primary key
-
-=item msgname - Name of the template.  This will appear in the user interface;
-if it needs to be localized for some users, add it to the message catalog.
-
-=item agentnum - Agent associated with this template.  Can be NULL for a 
-global template.
-
-=item mime_type - MIME type.  Defaults to text/html.
-
-=item from_addr - Source email address.
+FS::msg_template::email is a message processor in which the template contains 
+L<Text::Template> strings for the message subject line and body, and the 
+message is delivered by email.
 
-=item disabled - disabled ('Y' or NULL).
-
-=back
+Currently the C<from_addr> and C<bcc_addr> fields used by this processor are
+in the main msg_template table.
 
 =head1 METHODS
 
 =over 4
 
-=item new HASHREF
-
-Creates a new template.  To add the template to the database, see L<"insert">.
-
-Note that this stores the hash reference, not a distinct copy of the hash it
-points to.  You can ask the object for a copy with the I<hash> method.
-
-=cut
-
-# the new method can be inherited from FS::Record, if a table method is defined
-
-sub table { 'msg_template'; }
-
 =item insert [ CONTENT ]
 
 Adds this record to the database.  If there is an error, returns the error,
@@ -123,14 +86,6 @@ sub insert {
   return;
 }
 
-=item delete
-
-Delete this record from the database.
-
-=cut
-
-# the delete method can be inherited from FS::Record
-
 =item replace [ OLD_RECORD ] [ CONTENT ]
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
@@ -182,37 +137,6 @@ sub replace {
   $dbh->commit if $oldAutoCommit;
   return;
 }
-    
-
-
-=item check
-
-Checks all fields to make sure this is a valid template.  If there is
-an error, returns the error, otherwise returns false.  Called by the insert
-and replace methods.
-
-=cut
-
-# the check method should currently be supplied - FS::Record contains some
-# data checking routines
-
-sub check {
-  my $self = shift;
-
-  my $error = 
-    $self->ut_numbern('msgnum')
-    || $self->ut_text('msgname')
-    || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
-    || $self->ut_textn('mime_type')
-    || $self->ut_enum('disabled', [ '', 'Y' ] )
-    || $self->ut_textn('from_addr')
-  ;
-  return $error if $error;
-
-  $self->mime_type('text/html') unless $self->mime_type;
-
-  $self->SUPER::check;
-}
 
 =item content_locales
 
@@ -231,8 +155,7 @@ sub content_locales {
 
 =item prepare OPTION => VALUE
 
-Fills in the template and returns a hash of the 'from' address, 'to' 
-addresses, subject line, and body.
+Fills in the template and returns an L<FS::cust_msg> object.
 
 Options are passed as a list of name/value pairs:
 
@@ -271,16 +194,24 @@ invoicing_list addresses.  Multiple addresses may be comma-separated.
 
 A hash reference of additional substitutions
 
+=item msgtype
+
+A string identifying the kind of message this is. Currently can be "invoice", 
+"receipt", "admin", or null. Expand this list as necessary.
+
 =back
 
 =cut
 
 sub prepare {
+
   my( $self, %opt ) = @_;
 
   my $cust_main = $opt{'cust_main'}; # or die 'cust_main required';
   my $object = $opt{'object'} or die 'object required';
 
+  my $hashref = $self->prepare_substitutions(%opt);
+
   # localization
   my $locale = $cust_main && $cust_main->locale || '';
   warn "no locale for cust#".$cust_main->custnum."; using default content\n"
@@ -290,57 +221,7 @@ sub prepare {
   warn "preparing template '".$self->msgname."\n"
     if $DEBUG;
 
-  my $subs = $self->substitutions;
-
-  ###
-  # create substitution table
-  ###  
-  my %hash;
-  my @objects = ();
-  push @objects, $cust_main if $cust_main;
-  my @prefixes = ('');
-  my $svc;
-  if( ref $object ) {
-    if( ref($object) eq 'ARRAY' ) {
-      # [new, old], for provisioning tickets
-      push @objects, $object->[0], $object->[1];
-      push @prefixes, 'new_', 'old_';
-      $svc = $object->[0] if $object->[0]->isa('FS::svc_Common');
-    }
-    else {
-      push @objects, $object;
-      push @prefixes, '';
-      $svc = $object if $object->isa('FS::svc_Common');
-    }
-  }
-  if( $svc ) {
-    push @objects, $svc->cust_svc->cust_pkg;
-    push @prefixes, '';
-  }
-
-  foreach my $obj (@objects) {
-    my $prefix = shift @prefixes;
-    foreach my $name (@{ $subs->{$obj->table} }) {
-      if(!ref($name)) {
-        # simple case
-        $hash{$prefix.$name} = $obj->$name();
-      }
-      elsif( ref($name) eq 'ARRAY' ) {
-        # [ foo => sub { ... } ]
-        $hash{$prefix.($name->[0])} = $name->[1]->($obj);
-      }
-      else {
-        warn "bad msg_template substitution: '$name'\n";
-        #skip it?
-      } 
-    } 
-  } 
-
-  if ( $opt{substitutions} ) {
-    $hash{$_} = $opt{substitutions}->{$_} foreach keys %{$opt{substitutions}};
-  }
-
-  $_ = encode_entities($_ || '') foreach values(%hash);
+  $_ = encode_entities($_ || '') foreach values(%$hashref);
 
   ###
   # clean up template
@@ -349,7 +230,9 @@ sub prepare {
     TYPE   => 'STRING',
     SOURCE => $content->subject,
   );
-  my $subject = $subject_tmpl->fill_in( HASH => \%hash );
+
+  warn "$me filling in subject template\n" if $DEBUG;
+  my $subject = $subject_tmpl->fill_in( HASH => $hashref );
 
   my $body = $content->body;
   my ($skin, $guts) = eviscerate($body);
@@ -377,8 +260,9 @@ sub prepare {
     TYPE          => 'STRING',
     SOURCE        => $body,
   );
-
-  $body = $body_tmpl->fill_in( HASH => \%hash );
+  
+  warn "$me filling in body template\n" if $DEBUG;
+  $body = $body_tmpl->fill_in( HASH => $hashref );
 
   ###
   # and email
@@ -404,46 +288,146 @@ sub prepare {
     }
     $from_addr ||= $conf->invoice_from_full($agentnum);
   }
-#  my @cust_msg = ();
-#  if ( $conf->exists('log_sent_mail') and !$opt{'preview'} ) {
-#    my $cust_msg = FS::cust_msg->new({
-#        'custnum' => $cust_main->custnum,
-#        'msgnum'  => $self->msgnum,
-#        'status'  => 'prepared',
-#      });
-#    $cust_msg->insert;
-#    @cust_msg = ('cust_msg' => $cust_msg);
-#  }
 
   my $text_body = encode('UTF-8',
                   HTML::FormatText->new(leftmargin => 0, rightmargin => 70)
                       ->format( HTML::TreeBuilder->new_from_content($body) )
                   );
-  (
-    'custnum'   => ( $cust_main ? $cust_main->custnum : ''),
-    'msgnum'    => $self->msgnum,
+
+  warn "$me constructing MIME entities\n" if $DEBUG;
+  my %email = generate_email(
     'from'      => $from_addr,
     'to'        => \@to,
     'bcc'       => $self->bcc_addr || undef,
     'subject'   => $subject,
     'html_body' => $body,
-    'text_body' => $text_body
+    'text_body' => $text_body,
   );
 
+  warn "$me creating message headers\n" if $DEBUG;
+  my $env_from = $from_addr;
+  $env_from =~ s/^\s*//; $env_from =~ s/\s*$//;
+  if ( $env_from =~ /^(.*)\s*<(.*@.*)>$/ ) {
+    # a common idiom
+    $env_from = $2;
+  } 
+  
+  my $domain;
+  if ( $env_from =~ /\@([\w\.\-]+)/ ) {
+    $domain = $1;
+  } else {
+    warn 'no domain found in invoice from address '. $env_from .
+         '; constructing Message-ID (and saying HELO) @example.com'; 
+    $domain = 'example.com';
+  } 
+  my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
+
+  my $time = time;
+  my $message = MIME::Entity->build(
+    'From'        => $from_addr,
+    'To'          => join(', ', @to),
+    'Sender'      => $from_addr,
+    'Reply-To'    => $from_addr,
+    'Date'        => time2str("%a, %d %b %Y %X %z", $time),
+    'Subject'     => Encode::encode('MIME-Header', $subject),
+    'Message-ID'  => "<$message_id>",
+    'Encoding'    => '7bit',
+    'Type'        => 'multipart/related',
+  );
+
+  #$message->head->replace('Content-type',
+  #  'multipart/related; '.
+  #  'boundary="' . $message->head->multipart_boundary . '"; ' .
+  #  'type=multipart/alternative'
+  #);
+  
+  # XXX a facility to attach additional parts is necessary at some point
+  foreach my $part (@{ $email{mimeparts} }) {
+    warn "$me appending part ".$part->mime_type."\n" if $DEBUG;
+    $message->add_part( $part );
+  }
+
+  # effective To: address (not in headers)
+  push @to, $self->bcc_addr if $self->bcc_addr;
+  my $env_to = join(', ', @to);
+
+  my $cust_msg = FS::cust_msg->new({
+      'custnum'   => $cust_main->custnum,
+      'msgnum'    => $self->msgnum,
+      '_date'     => $time,
+      'env_from'  => $env_from,
+      'env_to'    => $env_to,
+      'header'    => $message->header_as_string,
+      'body'      => $message->body_as_string,
+      'error'     => '',
+      'status'    => 'prepared',
+      'msgtype'   => ($opt{'msgtype'} || ''),
+  });
+
+  return $cust_msg;
 }
 
-=item send OPTION => VALUE
+=item send_prepared CUST_MSG
 
-Fills in the template and sends it to the customer.  Options are as for 
-'prepare'.
+Takes the CUST_MSG object and sends it to its recipient.
 
 =cut
 
-# broken out from prepare() in case we want to queue the sending,
-# preview it, etc.
-sub send {
+sub send_prepared {
   my $self = shift;
-  send_email(generate_email($self->prepare(@_)));
+  my $cust_msg = shift or die "cust_msg required";
+
+  my $domain = 'example.com';
+  if ( $cust_msg->env_from =~ /\@([\w\.\-]+)/ ) {
+    $domain = $1;
+  }
+
+  my @to = split(/\s*,\s*/, $cust_msg->env_to);
+
+  my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
+                   'helo' => $domain );
+
+  my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
+  $smtp_opt{'port'} = $port;
+  
+  my $transport;
+  if ( defined($enc) && $enc eq 'starttls' ) {
+    $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
+    $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
+  } else {
+    if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
+      $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);     
+    } 
+    $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
+    $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
+  }
+
+  warn "$me sending message\n" if $DEBUG;
+  my $message = join("\n\n", $cust_msg->header, $cust_msg->body);
+  local $@;
+  eval {
+    sendmail( $message, { transport => $transport,
+                          from      => $cust_msg->env_from,
+                          to        => \@to })
+  };
+  my $error = '';
+  if(ref($@) and $@->isa('Email::Sender::Failure')) {
+    $error = $@->code.' ' if $@->code;
+    $error .= $@->message;
+  }
+  else {
+    $error = $@;
+  }
+
+  $cust_msg->set('error', $error);
+  $cust_msg->set('status', $error ? 'failed' : 'sent');
+  if ( $cust_msg->custmsgnum ) {
+    $cust_msg->replace;
+  } else {
+    $cust_msg->insert;
+  }
+
+  $error;
 }
 
 =item render OPTION => VALUE ...

-----------------------------------------------------------------------

Summary of changes:
 FS/FS/Schema.pm                                    |   24 +
 FS/FS/cust_main_Mixin.pm                           |   41 +-
 FS/FS/cust_msg.pm                                  |   44 +-
 FS/FS/msg_template.pm                              |  398 +++++---------
 FS/FS/msg_template/email.pm                        |  580 ++++++++++++++++++++
 FS/FS/msg_template/http.pm                         |  155 ++++++
 bin/msg_template_http-demo.pl                      |   76 +++
 .../{msg_template.html => msg_template/email.html} |   29 +-
 httemplate/browse/msg_template/http.html           |   68 +++
 httemplate/edit/msg_template.html                  |  383 +------------
 .../{msg_template.html => msg_template/email.html} |   11 +-
 httemplate/edit/msg_template/http.html             |   82 +++
 httemplate/edit/process/msg_template.html          |   18 +-
 httemplate/elements/htmlarea.html                  |    4 +-
 httemplate/elements/menu.html                      |    2 +-
 httemplate/elements/template_image-dialog.html     |    4 +-
 httemplate/misc/email-customers.html               |   31 +-
 17 files changed, 1261 insertions(+), 689 deletions(-)
 create mode 100644 FS/FS/msg_template/email.pm
 create mode 100644 FS/FS/msg_template/http.pm
 create mode 100755 bin/msg_template_http-demo.pl
 rename httemplate/browse/{msg_template.html => msg_template/email.html} (70%)
 create mode 100644 httemplate/browse/msg_template/http.html
 copy httemplate/edit/{msg_template.html => msg_template/email.html} (97%)
 create mode 100644 httemplate/edit/msg_template/http.html




More information about the freeside-commits mailing list