diff options
author | Ivan Kohler <ivan@freeside.biz> | 2015-10-23 16:17:51 -0700 |
---|---|---|
committer | Ivan Kohler <ivan@freeside.biz> | 2015-10-23 16:17:51 -0700 |
commit | 80c2d997c5c983344c530ecbb46f94f1c299b35f (patch) | |
tree | 28bafc1d55836acb5455e6554941c06f0912ef98 | |
parent | 3b1a5e8789d76f0581079d07190664f57bd773a3 (diff) | |
parent | 0806f9fdbf2086671c151b3bcb078dc008e96f6c (diff) |
Merge branch 'master' of git.freeside.biz:/home/git/freeside
68 files changed, 1965 insertions, 4667 deletions
diff --git a/FS/FS/Misc.pm b/FS/FS/Misc.pm index d06653edd..18cb275bf 100644 --- a/FS/FS/Misc.pm +++ b/FS/FS/Misc.pm @@ -284,7 +284,8 @@ sub send_email { 'status' => ($error ? 'failed' : 'sent'), 'msgtype' => $options{'msgtype'}, }); - $cust_msg->insert; # ignore errors + my $log_error = $cust_msg->insert; + warn "Error logging message: $log_error\n" if $log_error; # at least warn } $error; diff --git a/FS/FS/Report/Tax.pm b/FS/FS/Report/Tax.pm index 2480a45b9..a892a6b87 100644 --- a/FS/FS/Report/Tax.pm +++ b/FS/FS/Report/Tax.pm @@ -240,6 +240,25 @@ sub report_internal { $group "; + # also include the exempt-sales credit amount, for the credit report + $sql{exempt_credited} = "$select + SUM(COALESCE(exempt_credited, 0)) + FROM cust_main_county + LEFT JOIN ($exempt_credit) AS exempt_credit USING (taxnum) + JOIN cust_bill_pkg USING (billpkgnum) + $join_cust_pkg $where AND $nottax + $group + "; + + $all_sql{exempt_credited} = "$select_all + SUM(COALESCE(exempt_credited, 0)) + FROM cust_main_county + LEFT JOIN ($exempt_credit) AS exempt_credit USING (taxnum) + JOIN cust_bill_pkg USING (billpkgnum) + $join_cust_pkg $where AND $nottax + $group + "; + # taxable sales $sql{taxable} = "$select SUM(cust_bill_pkg.setup + cust_bill_pkg.recur @@ -339,12 +358,12 @@ sub report_internal { my $istax = "cust_bill_pkg.pkgnum = 0 and cust_bill_pkg.feepart is null"; - $sql{tax} = "$select SUM(cust_bill_pkg_tax_location.amount) + $sql{tax} = "$select COALESCE(SUM(cust_bill_pkg_tax_location.amount),0) $taxfrom $where AND $istax $group"; - $all_sql{tax} = "$select_all SUM(cust_bill_pkg_tax_location.amount) + $all_sql{tax} = "$select_all COALESCE(SUM(cust_bill_pkg_tax_location.amount),0) $taxfrom $where AND $istax $group_all"; @@ -364,12 +383,12 @@ sub report_internal { $creditwhere =~ s/cust_bill._date/cust_credit_bill._date/g; } - $sql{tax_credited} = "$select SUM(cust_credit_bill_pkg.amount) + $sql{tax_credited} = "$select COALESCE(SUM(cust_credit_bill_pkg.amount),0) $creditfrom $creditwhere AND $istax $group"; - $all_sql{tax_credited} = "$select_all SUM(cust_credit_bill_pkg.amount) + $all_sql{tax_credited} = "$select_all COALESCE(SUM(cust_credit_bill_pkg.amount),0) $creditfrom $creditwhere AND $istax $group_all"; @@ -385,12 +404,12 @@ sub report_internal { ' ON (cust_bill_pay_pkg.billpkgtaxlocationnum ='. ' cust_bill_pkg_tax_location.billpkgtaxlocationnum)'; - $sql{tax_paid} = "$select SUM(cust_bill_pay_pkg.amount) + $sql{tax_paid} = "$select COALESCE(SUM(cust_bill_pay_pkg.amount),0) $paidfrom $where AND $istax $group"; - $all_sql{tax_paid} = "$select_all SUM(cust_bill_pay_pkg.amount) + $all_sql{tax_paid} = "$select_all COALESCE(SUM(cust_bill_pay_pkg.amount),0) $paidfrom $where AND $istax $group_all"; @@ -562,6 +581,11 @@ sub table { $this_row{exempt_pkg} + $this_row{exempt_monthly} ); + $this_row{credits} = sprintf('%.2f', + $this_row{sales_credited} + + $this_row{exempt_credited} + + $this_row{tax_credited} + ); # and give it a label if ( $this_row{total} ) { $this_row{label} = 'Total'; diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 486860ff6..7dc54f7c4 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1962,6 +1962,24 @@ sub tables_hashref { ], }, + 'quotation_pkg_detail' => { + 'columns' => [ + 'detailnum', 'serial', '', '', '', '', + 'billpkgnum', 'int', '', '', '', '', # actually links to quotationpkgnum + 'format', 'char', 'NULL', 1, '', '', # not used for anything + 'detail', 'varchar', '', 255, '', '', + ], + 'primary_key' => 'detailnum', + 'unique' => [], + 'index' => [ [ 'billpkgnum' ] ], + 'foreign_keys' => [ + { columns => [ 'billpkgnum' ], + table => 'quotation_pkg', + references => [ 'quotationpkgnum' ], + }, + ], + }, + 'quotation_pkg_discount' => { 'columns' => [ 'quotationpkgdiscountnum', 'serial', '', '', '', '', @@ -2888,6 +2906,7 @@ sub tables_hashref { 'otaker', 'varchar', 'NULL', 32, '', '', 'usernum', 'int', 'NULL', '', '', '', 'disabled', 'char', 'NULL', 1, '', '', + 'setuprecur', 'char', 'NULL', 5, '', '', ], 'primary_key' => 'pkgdiscountnum', 'unique' => [], @@ -6544,6 +6563,25 @@ sub tables_hashref { ], }, + 'log_email' => { + 'columns' => [ + 'logemailnum', 'serial', '', '', '', '', + 'context', 'varchar', 'NULL', $char_d, '', '', + 'min_level', 'int', 'NULL', '', '', '', + 'msgnum', 'int', '', '', '', '', + 'to_addr', 'varchar', 'NULL', 255, '', '', + ], + 'primary_key' => 'logemailnum', + 'unique' => [], + 'index' => [ ['context'], ['min_level'] ], + 'foreign_keys' => [ + { columns => [ 'msgnum' ], + table => 'msg_template', + references => [ 'msgnum' ], + }, + ], + }, + 'svc_alarm' => { 'columns' => [ # name type null length default local diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index 1a3217c44..ffaef9707 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -3050,6 +3050,9 @@ sub _items_cust_bill_pkg { # if the current line item is waiting to go out, and the one we're about # to start is not bundled, then push out the current one and start a new # one. + if ( $d ) { + $d->{amount} = $d->{setup_amount} + $d->{recur_amount}; + } foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) { if ( $_ && !$cust_bill_pkg->hidden ) { $_->{amount} = sprintf( "%.2f", $_->{amount} ); @@ -3485,7 +3488,8 @@ sub _items_cust_bill_pkg { # $item_discount->{amount} is negative if ( $d and $cust_bill_pkg->hidden ) { - $d->{amount} += $item_discount->{amount}; + $d->{setup_amount} += $item_discount->{setup_amount}; + $d->{recur_amount} += $item_discount->{recur_amount}; } else { $d = $item_discount; $_ = &{$escape_function}($_) foreach @{ $d->{ext_description} }; @@ -3493,27 +3497,9 @@ sub _items_cust_bill_pkg { # update the active line (before the discount) to show the # original price (whether this is a hidden line or not) - # - # quotation discounts keep track of setup and recur; invoice - # discounts currently don't - if ( exists $item_discount->{setup_amount} ) { - - $s->{amount} -= $item_discount->{setup_amount} if $s; - $r->{amount} -= $item_discount->{recur_amount} if $r; - } else { - - # $active_line is the line item hashref for the line that will - # show the original price - # (use the recur or single line for the package, unless we're - # showing a setup line for a package with no recurring fee) - my $active_line = $r; - if ( $type eq 'S' ) { - $active_line = $s; - } - $active_line->{amount} -= $item_discount->{amount}; - - } + $s->{amount} -= $item_discount->{setup_amount} if $s; + $r->{amount} -= $item_discount->{recur_amount} if $r; } # if there are any discounts } # if this is an appropriate place to show discounts @@ -3522,6 +3508,11 @@ sub _items_cust_bill_pkg { } + # discount amount is internally split up + if ( $d ) { + $d->{amount} = $d->{setup_amount} + $d->{recur_amount}; + } + foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) { if ( $_ ) { $_->{amount} = sprintf( "%.2f", $_->{amount} ), diff --git a/FS/FS/TicketSystem/RT_Internal.pm b/FS/FS/TicketSystem/RT_Internal.pm index b70ac5360..1c4513e6d 100644 --- a/FS/FS/TicketSystem/RT_Internal.pm +++ b/FS/FS/TicketSystem/RT_Internal.pm @@ -572,7 +572,7 @@ sub _web_external_auth { # now get user specific information, to better create our user. my $new_user_info - = RT::Interface::Web::WebExternalAutoInfo($user); + = RT::Interface::Web::WebRemoteUserAutocreateInfo($user); # set the attributes that have been defined. # FIXME: this is a horrible kludge. I'm sure there's something cleaner diff --git a/FS/FS/cdr/amcom.pm b/FS/FS/cdr/amcom.pm index 43e6afd60..fee81f7df 100644 --- a/FS/FS/cdr/amcom.pm +++ b/FS/FS/cdr/amcom.pm @@ -27,8 +27,11 @@ my ($tmp_mday, $tmp_mon, $tmp_year); 'accountcode',# 2. BWGroupID (centrex group) sub { # 3. BWGroupNumber my ($cdr, $field) = @_; #, $conf, $hashref) = @_; - $cdr->charged_party($field) - if $cdr->accountcode eq '' && $field =~ /^(1800|1300)/; + + if ($cdr->accountcode eq '' && $field =~ /^(1800|1300)/){ + $cdr->charged_party($field); + $cdr->accountcode($field); + } }, 'uniqueid', # 4. Record ID sub { # 5. Call Category (LOCAL, NATIONAL, FREECALL, MOBILE) diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm index 178042666..5861ee47f 100644 --- a/FS/FS/cust_bill_pkg.pm +++ b/FS/FS/cust_bill_pkg.pm @@ -820,6 +820,8 @@ quantity. sub _item_discount { my $self = shift; + my %options = @_; + my @pkg_discounts = $self->pkg_discount; return if @pkg_discounts == 0; # special case: if there are old "discount details" on this line item, don't @@ -832,7 +834,8 @@ sub _item_discount { my $d = { _is_discount => 1, description => $self->mt('Discount'), - amount => 0, + setup_amount => 0, + recur_amount => 0, ext_description => \@ext, pkgpart => $self->pkgpart, feepart => $self->feepart, @@ -840,9 +843,11 @@ sub _item_discount { }; foreach my $pkg_discount (@pkg_discounts) { push @ext, $pkg_discount->description; - $d->{amount} -= $pkg_discount->amount; + my $setuprecur = $pkg_discount->cust_pkg_discount->setuprecur; + $d->{$setuprecur.'_amount'} -= $pkg_discount->amount; } - $d->{amount} *= $self->quantity || 1; + $d->{setup_amount} *= $self->quantity || 1; # ?? + $d->{recur_amount} *= $self->quantity || 1; # ?? return $d; } diff --git a/FS/FS/cust_bill_pkg_discount.pm b/FS/FS/cust_bill_pkg_discount.pm index 9e64d2076..616657a4f 100644 --- a/FS/FS/cust_bill_pkg_discount.pm +++ b/FS/FS/cust_bill_pkg_discount.pm @@ -135,10 +135,36 @@ Returns a string describing the discount (for use on an invoice). sub description { my $self = shift; my $discount = $self->cust_pkg_discount->discount; + + if ( $self->months == 0 ) { + # then this is a setup discount + my $desc = $discount->name; + if ( $desc ) { + $desc .= ': '; + } else { + $desc = $self->mt('Setup discount of '); + } + if ( (my $percent = $discount->percent) > 0 ) { + $percent = sprintf('%.1f', $percent) if $percent > int($percent); + $percent =~ s/\.0+$//; + $desc .= $percent . '%'; + } else { + # note "$self->amount", not $discount->amount. if a flat discount + # is applied to the setup fee, show the amount actually discounted. + # we might do this for all types of discounts. + my $money_char = FS::Conf->new->config('money_char') || '$'; + $desc .= $money_char . sprintf('%.2f', $self->amount); + } + + # don't show "/month", months remaining or used, etc., as for setup + # discounts it doesn't matter. + return $desc; + } + my $desc = $discount->description_short; $desc .= $self->mt(' each') if $self->cust_bill_pkg->quantity > 1; - if ($discount->months) { + if ( $discount->months and $self->months > 0 ) { # calculate months remaining on this cust_pkg_discount after this invoice my $date = $self->cust_bill_pkg->cust_bill->_date; my $used = FS::Record->scalar_sql( @@ -152,7 +178,7 @@ sub description { $used ||= 0; my $remaining = sprintf('%.2f', $discount->months - $used); $desc .= $self->mt(' for [quant,_1,month] ([quant,_2,month] remaining)', - $self->months, + sprintf('%.2f', $self->months), $remaining ); } diff --git a/FS/FS/cust_main/Billing_Batch.pm b/FS/FS/cust_main/Billing_Batch.pm index c2416ab32..0cf2beeda 100644 --- a/FS/FS/cust_main/Billing_Batch.pm +++ b/FS/FS/cust_main/Billing_Batch.pm @@ -51,10 +51,22 @@ sub batch_card { return '' unless $amount > 0; my $invnum = delete $options{invnum}; - my $payby = $options{payby} || $self->payby; #still dubious + + #false laziness with Billing_Realtime + my @cust_payby = qsearch({ + 'table' => 'cust_payby', + 'hashref' => { 'custnum' => $self->custnum, }, + 'extra_sql' => " AND payby IN ( 'CARD', 'CHEK' ) ", + 'order_by' => 'ORDER BY weight ASC', + }); + + # batch can't try out every one like realtime, just use first one + my $cust_payby = $cust_payby[0] || $self; # somewhat dubious + + my $payby = $options{payby} || $cust_payby->payby; if ($options{'realtime'}) { - return $self->realtime_bop( FS::payby->payby2bop($self->payby), + return $self->realtime_bop( FS::payby->payby2bop($payby), $amount, %options, ); @@ -114,10 +126,10 @@ sub batch_card { 'state' => $options{state} || $loc->state, 'zip' => $options{zip} || $loc->zip, 'country' => $options{country} || $loc->country, - 'payby' => $options{payby} || $self->payby, - 'payinfo' => $options{payinfo} || $self->payinfo, - 'exp' => $options{paydate} || $self->paydate, - 'payname' => $options{payname} || $self->payname, + 'payby' => $options{payby} || $cust_payby->payby, + 'payinfo' => $options{payinfo} || $cust_payby->payinfo, + 'exp' => $options{paydate} || $cust_payby->paydate, + 'payname' => $options{payname} || $cust_payby->payname, 'amount' => $amount, # consolidating } ); diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 434815c16..c2ce680a1 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -888,6 +888,7 @@ sub _realtime_bop_result { '_date' => '', 'payby' => $cust_pay_pending->payby, 'payinfo' => $options{'payinfo'}, + 'paymask' => $options{'paymask'}, 'paydate' => $cust_pay_pending->paydate, 'pkgnum' => $cust_pay_pending->pkgnum, 'discount_term' => $options{'discount_term'}, diff --git a/FS/FS/cust_main/Packages.pm b/FS/FS/cust_main/Packages.pm index c147e551c..ead97f2c3 100644 --- a/FS/FS/cust_main/Packages.pm +++ b/FS/FS/cust_main/Packages.pm @@ -197,7 +197,7 @@ sub order_pkg { map { $_ => $cust_pkg->$_() } qw( pkgbatch start_date order_date expire adjourn contract_end - refnum discountnum waive_setup + refnum setup_discountnum recur_discountnum waive_setup ) }); $error = $self->order_pkg('cust_pkg' => $pkg, diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index 279205b19..d7419078b 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -425,7 +425,7 @@ sub insert { } } - if ( $self->discountnum ) { + if ( $self->setup_discountnum || $self->recur_discountnum ) { my $error = $self->insert_discount(); if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -4318,13 +4318,10 @@ sub insert_reason { Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly inserting a new discount on the fly (see L<FS::discount>). -Available options are: - -=over 4 - -=item discountnum - -=back +This will look at the cust_pkg for a pseudo-field named "setup_discountnum", +and if present, will create a setup discount. If the discountnum is -1, +a new discount definition will be inserted using the value in +"setup_discountnum_amount" or "setup_discountnum_percent". Likewise for recur. If there is an error, returns the error, otherwise returns false. @@ -4334,21 +4331,29 @@ sub insert_discount { #my ($self, %options) = @_; my $self = shift; - my $cust_pkg_discount = new FS::cust_pkg_discount { - 'pkgnum' => $self->pkgnum, - 'discountnum' => $self->discountnum, - 'months_used' => 0, - 'end_date' => '', #XXX - #for the create a new discount case - '_type' => $self->discountnum__type, - 'amount' => $self->discountnum_amount, - 'percent' => $self->discountnum_percent, - 'months' => $self->discountnum_months, - 'setup' => $self->discountnum_setup, - #'disabled' => $self->discountnum_disabled, - }; + foreach my $x (qw(setup recur)) { + if ( my $discountnum = $self->get("${x}_discountnum") ) { + my $cust_pkg_discount = FS::cust_pkg_discount->new( { + 'pkgnum' => $self->pkgnum, + 'discountnum' => $discountnum, + 'setuprecur' => $x, + 'months_used' => 0, + 'end_date' => '', #XXX + #for the create a new discount case + 'amount' => $self->get("${x}_discountnum_amount"), + 'percent' => $self->get("${x}_discountnum_percent"), + 'months' => $self->get("${x}_discountnum_months"), + } ); + if ( $x eq 'setup' ) { + $cust_pkg_discount->setup('Y'); + $cust_pkg_discount->months(''); + } + my $error = $cust_pkg_discount->insert; + return $error if $error; + } + } - $cust_pkg_discount->insert; + ''; } =item set_usage USAGE_VALUE_HASHREF diff --git a/FS/FS/cust_pkg_discount.pm b/FS/FS/cust_pkg_discount.pm index 5d0f85b5e..aa8981621 100644 --- a/FS/FS/cust_pkg_discount.pm +++ b/FS/FS/cust_pkg_discount.pm @@ -59,6 +59,9 @@ end_date order taker, see L<FS::access_user> +=item setuprecur + +whether this discount applies to setup fees or recurring fees =back @@ -125,11 +128,29 @@ sub check { || $self->ut_alphan('otaker') || $self->ut_numbern('usernum') || $self->ut_enum('disabled', [ '', 'Y' ] ) + || $self->ut_enum('setuprecur', [ 'setup', 'recur' ] ) ; return $error if $error; - return "Discount does not apply to setup fees, and package has no recurring" - if ! $self->discount->setup && $self->cust_pkg->part_pkg->freq =~ /^0/; + my $cust_pkg = $self->cust_pkg; + my $discount = $self->discount; + if ( $self->setuprecur eq 'setup' ) { + if ( !$discount->setup ) { + # UI prevents this, and historical discounts should never have it either + return "Discount #".$self->discountnum." can't be applied to setup fees."; + } elsif ( $cust_pkg->base_setup == 0 ) { + # and this + return "Can't apply setup discount to a package with no setup fee."; + } + # else we're good. do NOT disallow applying setup discounts when the + # setup date is already set; upgrades use that. + } else { + if ( $self->cust_pkg->base_recur == 0 ) { + return "Can't apply recur discount to a package with no recurring fee."; + } elsif ( $cust_pkg->part_pkg->freq eq '0' ) { + return "Can't apply recur discount to a one-time charge."; + } + } $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum; @@ -205,6 +226,45 @@ sub status { sub _upgrade_data { # class method my ($class, %opts) = @_; $class->_upgrade_otaker(%opts); + + # #14092: set setuprecur field on discounts. if we get one that applies to + # both setup and recur, split it into two discounts. + my $search = FS::Cursor->new({ + table => 'cust_pkg_discount', + hashref => { setuprecur => '' } + }); + while ( my $cust_pkg_discount = $search->fetch ) { + my $discount = $cust_pkg_discount->discount; + my $cust_pkg = $cust_pkg_discount->cust_pkg; + # 1. Does it apply to the setup fee? + # Yes, if: the discount applies to setup fees generally, and the package + # has a setup fee. + # No, if: the discount is a flat amount, and is not first-month only. + if ( $discount->setup + and $cust_pkg->base_setup > 0 + and ($discount->amount == 0 or $discount->months == 1) + ) + { + # then clone this discount into a new one + my $setup_discount = FS::cust_pkg_discount->new({ + $cust_pkg_discount->hash, + setuprecur => 'setup', + pkgdiscountnum => '' + }); + my $error = $setup_discount->insert; + die "$error (migrating cust_pkg_discount to setup discount)" if $error; + } + # 2. Does it apply to the recur fee? + # Yes, if: the package has a recur fee. + if ( $cust_pkg->base_recur > 0 ) { + # then modify this discount in place + $cust_pkg_discount->set('setuprecur' => 'recur'); + my $error = $cust_pkg_discount->replace; + die "$error (migrating cust_pkg_discount)" if $error; + } + # not in here yet: splitting the cust_bill_pkg_discount records. + # (not really necessary) + } } =back diff --git a/FS/FS/discount.pm b/FS/FS/discount.pm index e11335741..13146a91b 100644 --- a/FS/FS/discount.pm +++ b/FS/FS/discount.pm @@ -119,12 +119,12 @@ sub check { if ( $self->_type eq 'Select discount type' ) { return 'Please select a discount type'; - } elsif ( $self->_type eq 'Amount' ) { - $self->percent('0'); - return 'Amount must be greater than 0' unless $self->amount > 0; - } elsif ( $self->_type eq 'Percentage' ) { - $self->amount('0.00'); - return 'Percentage must be greater than 0' unless $self->percent > 0; + } elsif ( $self->amount > 0 ) { + $self->set('percent', '0'); + } elsif ( $self->percent > 0 ) { + $self->set('amount', '0.00'); + } else { + return "Discount amount or percentage must be > 0"; } my $error = diff --git a/FS/FS/log.pm b/FS/FS/log.pm index a4ad214d0..b07910528 100644 --- a/FS/FS/log.pm +++ b/FS/FS/log.pm @@ -5,6 +5,7 @@ use base qw( FS::Record ); use FS::Record qw( qsearch qsearchs dbdef ); use FS::UID qw( dbh driver_name ); use FS::log_context; +use FS::log_email; =head1 NAME @@ -71,6 +72,8 @@ otherwise returns false. CONTEXT may be a list of context tags to attach to this record. +Will send emails according to the conditions in L<FS::log_email>. + =cut sub insert { @@ -78,6 +81,7 @@ sub insert { my $self = shift; my $error = $self->SUPER::insert; return $error if $error; + my $contexts = {}; #for quick checks when sending emails foreach ( @_ ) { my $context = FS::log_context->new({ 'lognum' => $self->lognum, @@ -85,11 +89,40 @@ sub insert { }); $error = $context->insert; return $error if $error; + $contexts->{$_} = 1; + } + foreach my $log_email ( + qsearch('log_email', + { + 'disabled' => '', + 'min_level' => { + 'op' => '<=', + 'value' => $self->level, + }, + } + ) + ) { + # shouldn't be a lot of these, so not packing this into the qsearch + next if $log_email->context && !$contexts->{$log_email->context}; + my $msg_template = qsearchs('msg_template',{ 'msgnum' => $log_email->msgnum }); + unless ($msg_template) { + warn "Could not send email when logging, could not load message template for logemailnum " . $log_email->logemailnum; + next; + } + my $emailerror = $msg_template->send( + 'to' => $log_email->to_addr, + 'substitutions' => { + 'loglevel' => $FS::Log::LEVELS[$self->level], # which has hopefully been loaded... + 'logcontext' => $log_email->context, # use the one that triggered the email + 'logmessage' => $self->message, + }, + ); + warn "Could not send email when logging: $emailerror" if $emailerror; } ''; } -# the insert method can be inherited from FS::Record +# these methods can be inherited from FS::Record sub delete { die "Log entries can't be modified." }; diff --git a/FS/FS/log_email.pm b/FS/FS/log_email.pm new file mode 100644 index 000000000..9c53c230a --- /dev/null +++ b/FS/FS/log_email.pm @@ -0,0 +1,108 @@ +package FS::log_email; + +use strict; +use base qw( FS::Record ); +use FS::Record qw( qsearch qsearchs dbdef ); +use FS::UID qw( dbh driver_name ); + +=head1 NAME + +FS::log_email - Object methods for log email records + +=head1 SYNOPSIS + + use FS::log_email; + + $record = new FS::log_email \%hash; + $record = new FS::log_email { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::log object represents the conditions for sending an email +when a log entry is created. FS::log inherits from FS::Record. +The following fields are currently supported: + +=over 4 + +=item logemailnum - primary key + +=item context - the context that will trigger the email (all contexts if unspecified) + +=item min_level - the minimum log level that will trigger the email (all levels if unspecified) + +=item msgnum - the msg_template that will be used to send the email + +=item to_addr - who the email will be sent to (in addition to any bcc on the template) + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new log_email entry. + +=cut + +sub table { 'log_email'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=item delete + +Delete this record from the database. + +=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. + +=item check + +Checks all fields to make sure this is a valid record. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('logemailnum') + || $self->ut_textn('context') # not validating against list of contexts in log_context, + # because not even log_context check currently does so + || $self->ut_number('min_level') + || $self->ut_foreign_key('msgnum', 'msg_template', 'msgnum') + || $self->ut_textn('to_addr') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 BUGS + +=head1 SEE ALSO + +L<FS::Record>, schema.html from the base documentation. + +=cut + +1; + diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm index d17fd41cb..1d357b1a3 100644 --- a/FS/FS/msg_template.pm +++ b/FS/FS/msg_template.pm @@ -274,7 +274,7 @@ Options are passed as a list of name/value pairs: =item cust_main -Customer object (required). +Customer object =item object @@ -324,7 +324,7 @@ 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'; + my $object = $opt{'object'}; # or die 'object required'; warn "preparing substitutions for '".$self->msgname."'\n" if $DEBUG; diff --git a/FS/FS/msg_template/email.pm b/FS/FS/msg_template/email.pm index 377dbb17b..83ff18f19 100644 --- a/FS/FS/msg_template/email.pm +++ b/FS/FS/msg_template/email.pm @@ -164,7 +164,7 @@ Options are passed as a list of name/value pairs: =item cust_main -Customer object (required). +Customer object =item object @@ -215,7 +215,7 @@ 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 $object = $opt{'object'}; # or die 'object required'; my $hashref = $self->prepare_substitutions(%opt); @@ -365,7 +365,7 @@ sub prepare { my $env_to = join(', ', @to); my $cust_msg = FS::cust_msg->new({ - 'custnum' => $cust_main->custnum, + 'custnum' => $cust_main ? $cust_main->custnum : '', 'msgnum' => $self->msgnum, '_date' => $time, 'env_from' => $env_from, diff --git a/FS/FS/part_pkg/agent.pm b/FS/FS/part_pkg/agent.pm index e1165c4c4..1a5b6154e 100644 --- a/FS/FS/part_pkg/agent.pm +++ b/FS/FS/part_pkg/agent.pm @@ -14,8 +14,8 @@ $DEBUG = 0; $me = '[FS::part_pkg::agent]'; %info = ( - 'name' => 'Wholesale bulk billing, for master customers of an agent.', - 'shortname' => 'Wholesale bulk billing for agent', + 'name' => 'Wholesale billing based on package prices, for master customers of an agent.', + 'shortname' => 'Wholesale billing for agent (package prices)', 'inherit_fields' => [qw( prorate global_Mixin)], 'fields' => { #'recur_method' => { 'name' => 'Recurring fee method', @@ -94,12 +94,13 @@ sub calc_recur { if $DEBUG; #make sure setup dates are filled in - my $error = $cust_main->bill; #options don't propogate from freeside-daily + my $error = $cust_main->bill( time => $$sdate ); die "Error pre-billing agent customer: $error" if $error; my @cust_pkg = grep { my $setup = $_->get('setup'); my $cancel = $_->get('cancel'); + #$setup <= $$sdate # ? $setup < $$sdate # END && ( ! $cancel || $cancel > $last_bill ) #START } diff --git a/FS/FS/part_pkg/agent_invoice.pm b/FS/FS/part_pkg/agent_invoice.pm new file mode 100644 index 000000000..0e1e6fe1d --- /dev/null +++ b/FS/FS/part_pkg/agent_invoice.pm @@ -0,0 +1,225 @@ +package FS::part_pkg::agent_invoice; +use base qw(FS::part_pkg::recur_Common); + +use strict; +use FS::Record qw( qsearch ); +use FS::agent; +use FS::cust_main; +use FS::cust_bill_pkg_detail; +use Date::Format 'time2str'; +use Text::CSV; + +our $DEBUG = 0; + +our $me = '[FS::part_pkg::agent_invoice]'; + +tie my %itemize_options, 'Tie::IxHash', + 'cust_bill' => 'one line per invoice', + 'cust_main' => 'one line per customer', + 'agent' => 'one line per agent', +; + +# use detail_formats for this? +my %itemize_header = ( + 'cust_bill' => '"Inv #","Customer","Date","Charge"', + 'cust_main' => '"Cust #","Customer","Charge"', + 'agent' => '', +); + +our %info = ( + 'name' => 'Wholesale bulk billing based on actual invoice amounts, for master customers of an agent.', + 'shortname' => 'Wholesale billing for agent (invoice amounts)', + 'inherit_fields' => [qw( prorate_Mixin global_Mixin) ], + 'fields' => { + 'cutoff_day' => { 'name' => 'Billing Day (1 - 28) for prorating or '. + 'subscription', + 'default' => '1', + }, + + 'recur_method' => { 'name' => 'Recurring fee method', + #'type' => 'radio', + #'options' => \%recur_method, + 'type' => 'select', + 'select_options' => \%FS::part_pkg::recur_Common::recur_method, + }, + 'multiplier' => { 'name' => 'Percentage of billed amount to charge' }, + 'itemize' => { 'name' => 'Display on the wholesale invoice', + 'type' => 'select', + 'select_options' => \%itemize_options, + }, + }, + 'fieldorder' => [ qw( recur_method cutoff_day multiplier itemize ), + FS::part_pkg::prorate_Mixin::fieldorder, + ], + + 'weight' => 53, + +); + +#some false laziness-ish w/ the other agent plan +sub calc_recur { + my $self = shift; + my($cust_pkg, $sdate, $details, $param ) = @_; + + my $csv = Text::CSV->new({ binary => 1 }); + + my $itemize = $self->option('itemize') || 'cust_bill'; + my $last_bill = $cust_pkg->last_bill; + + my $conf = new FS::Conf; +# my $money_char = $conf->config('money_char') || '$'; + + warn "$me billing for agent packages from ". time2str('%x', $last_bill). + " to ". time2str('%x', $$sdate). "\n" + if $DEBUG; + + # only invoices dated after $last_bill, but before or on $$sdate, will be + # included in this wholesale bundle. + # $last_bill is the last date the wholesale package was billed, unless + # it has never been billed before, in which case it's the current time. + # $$sdate is the date of the invoice we are now generating. It is one of: + # - the bill date we are now billing, if there is one. + # - or the wholesale package's setup date, if there is one + # - or the current time + # It will usually not be _after_ the current time. This can still happen + # if this package's bill date is later in the current day than right now, + # and next-bill-ignore-time is on. + my $date_range = " AND _date <= $$sdate"; + if ( $last_bill ) { + $date_range .= " AND _date > $last_bill"; + } + + my $percent = $self->option('multiplier') || 100; + + my $charged_cents = 0; + my $wholesale_cents = 0; + my $total = 0; + + my @agents = qsearch('agent', { 'agent_custnum' => $cust_pkg->custnum } ); + + # The existing "agent" plan (based on package defined charges/costs) has to + # ensure that all the agent's customers are billed before calculating its + # fee, so that it can tell which packages were charged within the period. + # _This_ plan has to do it because it actually uses the invoices. Either way + # this behavior is not ideal, especially when using freeside-daily in + # multiprocess mode, since it can lead to billing all of the agent's + # customers within the master customer's billing job. If this becomes a + # problem, one option is to use "freeside-daily -a" to bill the agent's + # customers _first_, and master customers later. + foreach my $agent (@agents) { + + warn "$me billing for agent ". $agent->agent. "\n" + if $DEBUG; + + # cursor this if memory usage becomes a problem + my @cust_main = qsearch('cust_main', { 'agentnum' => $agent->agentnum } ); + + foreach my $cust_main (@cust_main) { + + warn "$me billing agent charges for ". $cust_main->name_short. "\n" + if $DEBUG; + + # this option at least should propagate, or we risk generating invoices + # in the apparent future and then leaving them out of this group + my $error = $cust_main->bill( 'time' => $$sdate ); + + die "Error pre-billing agent customer: $error" if $error; + + my @cust_bill = qsearch({ + table => 'cust_bill', + hashref => { 'custnum' => $cust_main->custnum }, + extra_sql => $date_range, + order_by => ' ORDER BY _date ASC', + }); + + foreach my $cust_bill (@cust_bill) { + + # do we want the itemize setting to be purely cosmetic, or to actually + # change how the calculation is done? for now let's make it purely + # cosmetic, and round at the level of the individual invoice. can + # change this if needed. + $charged_cents += $cust_bill->charged * 100; + $wholesale_cents += sprintf('%.0f', $cust_bill->charged * $percent); + + if ( $itemize eq 'cust_bill' ) { + $csv->combine( + $cust_bill->invnum, + $cust_main->name_short, + $cust_main->time2str_local('short', $cust_bill->_date), + sprintf('%.2f', $wholesale_cents / 100), + ); + my $detail = FS::cust_bill_pkg_detail->new({ + format => 'C', + startdate => $cust_bill->_date, + amount => sprintf('%.2f', $wholesale_cents / 100), + detail => $csv->string, + }); + push @$details, $detail; + + $total += $wholesale_cents; + $charged_cents = $wholesale_cents = 0; + } + + } + + if ( $itemize eq 'cust_main' ) { + + $csv->combine( + $cust_main->custnum, + $cust_main->name_short, + sprintf('%.2f', $wholesale_cents / 100), + ); + my $detail = FS::cust_bill_pkg_detail->new({ + format => 'C', + amount => sprintf('%.2f', $wholesale_cents / 100), + detail => $csv->string, + }); + push @$details, $detail; + + $total += $wholesale_cents; + $charged_cents = $wholesale_cents = 0; + } + + } # foreach $cust_main + + if ( $itemize eq 'agent' ) { + $csv->combine( + $cust_pkg->mt('[_1] customers', $agent->agent), + sprintf('%.2f', $wholesale_cents / 100), + ); + my $detail = FS::cust_bill_pkg_detail->new({ + format => 'C', + amount => sprintf('%.2f', $wholesale_cents / 100), + detail => $csv->string, + }); + push @$details, $detail; + + $total += $wholesale_cents; + $charged_cents = $wholesale_cents = 0; + } + + } # foreach $agent + + if ( @$details and $itemize_header{$itemize} ) { + unshift @$details, FS::cust_bill_pkg_detail->new({ + format => 'C', + detail => $itemize_header{$itemize}, + }); + } + + my $charges = ($total / 100) + $self->calc_recur_Common(@_); + + sprintf('%.2f', $charges ); + +} + +sub can_discount { 0; } + +sub hide_svc_detail { 1; } + +sub is_free { 0; } + +sub can_usageprice { 0; } + +1; + diff --git a/FS/FS/part_pkg/discount_Mixin.pm b/FS/FS/part_pkg/discount_Mixin.pm index 5de7d8ea5..1e39f6aef 100644 --- a/FS/FS/part_pkg/discount_Mixin.pm +++ b/FS/FS/part_pkg/discount_Mixin.pm @@ -50,6 +50,9 @@ sub calc_discount { my $tot_discount = 0; #UI enforces just 1 for now, will need ordering when they can be stacked + # discount setup/recur splitting DOES NOT TOUCH THIS YET. + # we need some kind of monitoring to see who if anyone still uses term + # discounts. if ( $param->{freq_override} ) { # When a customer pays for more than one month at a time to receive a # term discount, freq_override is set to the number of months. @@ -80,6 +83,13 @@ sub calc_discount { } my @cust_pkg_discount = $cust_pkg->cust_pkg_discount_active; + + if ( defined $param->{'setup_charge'} ) { + @cust_pkg_discount = grep { $_->setuprecur eq 'setup' } @cust_pkg_discount; + } else { + @cust_pkg_discount = grep { $_->setuprecur eq 'recur' } @cust_pkg_discount; + } + foreach my $cust_pkg_discount ( @cust_pkg_discount ) { my $discount_left; my $discount = $cust_pkg_discount->discount; @@ -115,23 +125,17 @@ sub calc_discount { # if it's a flat amount discount for other than one month: # - skip the discount. unsure, leaving it alone for now. - next unless $discount->setup; - $months = 0; # never count a setup discount as a month of discount # (the recur discount in the same month should do it) if ( $discount->percent > 0 ) { $amount = $discount->percent * $param->{'setup_charge'} / 100; - } elsif ( $discount->amount > 0 && ($discount->months || 0) == 1) { + } elsif ( $discount->amount > 0 ) { # apply the discount amount, up to a maximum of the setup charge $amount = min($discount->amount, $param->{'setup_charge'}); $discount_left = sprintf('%.2f', $discount->amount - $amount); # transfer remainder of discount, if any, to recur $param->{'discount_left_recur'}{$discount->discountnum} = $discount_left; - } else { - # I guess we don't allow multiple-month flat amount discounts to - # apply to setup? - next; } } else { @@ -180,20 +184,27 @@ sub calc_discount { # recur discount is zero. #} - # transfer remainder of discount, if any, to setup - # this is used when the recur phase wants to add a setup fee + # Transfer remainder of discount, if any, to setup + # This is used when the recur phase wants to add a setup fee # (prorate_defer_bill): the "discount_left_setup" amount will - # be subtracted in _make_lines. - if ( $discount->setup && $discount->amount > 0 - && ($discount->months || 0) != 1 - ) + # be subtracted in _make_lines. + if ( $discount->amount > 0 && ($discount->months || 0) != 1 ) { - # $amount is no longer permonth at this point! correct. very good. - $discount_left = $amount - $recur_charge; # backward, as above - if ( $discount_left > 0 ) { - $amount = $recur_charge; - $param->{'discount_left_setup'}{$discount->discountnum} = - 0 - $discount_left; + # make sure there is a setup discount with this discountnum + # on the same package. + if ( qsearchs('cust_pkg_discount', { + pkgnum => $cust_pkg->pkgnum, + discountnum => $discount->discountnum, + setuprecur => 'setup' + }) ) + { + # $amount is no longer permonth at this point! correct. very good. + $discount_left = $amount - $recur_charge; # backward, as above + if ( $discount_left > 0 ) { + $amount = $recur_charge; + $param->{'discount_left_setup'}{$discount->discountnum} = + 0 - $discount_left; + } } } @@ -210,7 +221,7 @@ sub calc_discount { }; } - } + } # else not 'setup_charge' $amount = sprintf('%.2f', $amount + 0.00000001 ); #so 1.005 rounds to 1.01 @@ -221,7 +232,7 @@ sub calc_discount { 'pkgdiscountnum' => $cust_pkg_discount->pkgdiscountnum, 'amount' => $amount, 'months' => $months, - # XXX should have a 'setuprecur' + # 'setuprecur' is implied by the cust_pkg_discount link }; push @{ $param->{'discounts'} }, $cust_bill_pkg_discount; $tot_discount += $amount; diff --git a/FS/FS/pay_batch.pm b/FS/FS/pay_batch.pm index d7dd7bbe4..e299dd9c7 100644 --- a/FS/FS/pay_batch.pm +++ b/FS/FS/pay_batch.pm @@ -903,8 +903,6 @@ sub prepare_for_export { my $status = $self->status; if ($status eq 'O') { $first_download = 1; - my $error = $self->set_status('I'); - return "error updating pay_batch status: $error\n" if $error; } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) { $first_download = 0; } elsif ($status eq 'R' && @@ -938,7 +936,7 @@ sub prepare_for_export { my $balance = $cust_pay_batch->cust_main->balance; if ($balance <= 0) { # then don't charge this customer - my $error = $cust_pay_batch->delete; + my $error = $cust_pay_batch->unbatch_and_delete; return $error if $error; } elsif ($balance < $cust_pay_batch->amount) { # reduce the charge to the remaining balance @@ -948,6 +946,20 @@ sub prepare_for_export { } # else $balance >= $cust_pay_batch->amount } + + # we might end up removing all cust_pay_batch above... + # probably the better way to handle this is to commit that removal, + # but no time to trace code & test that right now + # + # additionally, UI currently allows hand-deletion of all payments from a batch, meaning + # it's possible to try and process an empty batch...this is where we catch + # such an attempt, though it probably shouldn't be possible in the first place + return "Batch is empty" unless $self->cust_pay_batch; + + #need to do this after unbatch_and_delete + my $error = $self->set_status('I'); + return "error updating pay_batch status: $error\n" if $error; + } #if $first_download ''; diff --git a/FS/FS/quotation.pm b/FS/FS/quotation.pm index f82051066..d66b1b8e4 100644 --- a/FS/FS/quotation.pm +++ b/FS/FS/quotation.pm @@ -972,11 +972,12 @@ sub _items_pkg { foreach my $quotation_pkg (@pkgs) { my $part_pkg = $quotation_pkg->part_pkg; + my @details = $quotation_pkg->details; my $setuprecur; my $this_item = { 'pkgnum' => $quotation_pkg->quotationpkgnum, 'description' => $quotation_pkg->desc($locale), - 'ext_description' => [], + 'ext_description' => \@details, 'quantity' => $quotation_pkg->quantity, }; if ($freq eq '0') { diff --git a/FS/FS/quotation_pkg.pm b/FS/FS/quotation_pkg.pm index 4c78be75c..10bdc2efe 100644 --- a/FS/FS/quotation_pkg.pm +++ b/FS/FS/quotation_pkg.pm @@ -5,6 +5,7 @@ use strict; use FS::Record qw( qsearchs qsearch dbh ); use FS::part_pkg; use FS::quotation_pkg_discount; #so its loaded when TemplateItem_Mixin needs it +use FS::quotation_pkg_detail; use List::Util qw(sum); =head1 NAME @@ -101,6 +102,20 @@ sub display_table { 'quotation_pkg'; } sub discount_table { 'quotation_pkg_discount'; } +# detail table uses non-quotation fieldnames, see billpkgnum below +sub detail_table { 'quotation_pkg_detail'; } + +=item billpkgnum + +Sets/returns quotationpkgnum, for ease of integration with TemplateItem_Mixin::details + +=cut + +sub billpkgnum { + my $self = shift; + $self->quotationpkgnum(@_); +} + =item insert Adds this record to the database. If there is an error, returns the error, @@ -145,15 +160,21 @@ sub delete { my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; + my $error = $self->delete_details; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + foreach ($self->quotation_pkg_discount, $self->quotation_pkg_tax) { - my $error = $_->delete; + $error = $_->delete; if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error . ' (deleting discount)'; } } - my $error = $self->SUPER::delete; + $error = $self->SUPER::delete; if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; @@ -329,6 +350,69 @@ sub part_pkg_currency_option { } } +=item delete_details + +Deletes all quotation_pkgs_details associated with this pkg (see L<FS::quotation_pkg_detail>). + +=cut + +sub delete_details { + my $self = shift; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + foreach my $detail ( qsearch('quotation_pkg_detail',{ 'billpkgnum' => $self->quotationpkgnum }) ) { + my $error = $detail->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "error removing old detail: $error"; + } + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; + +} + +=item set_details [ DETAIL, DETAIL, ... ] + +Sets quotation details for this package (see L<FS::quotation_pkg_detail>). + +If there is an error, returns the error, otherwise returns false. + +=cut + +sub set_details { + my( $self, @details ) = @_; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + my $error = $self->delete_details; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + foreach my $detail ( @details ) { + my $quotation_pkg_detail = new FS::quotation_pkg_detail { + 'billpkgnum' => $self->quotationpkgnum, + 'detail' => $detail, + }; + $error = $quotation_pkg_detail->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "error adding new detail: $error"; + } + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; + +} =item cust_bill_pkg_display [ type => TYPE ] diff --git a/FS/FS/quotation_pkg_detail.pm b/FS/FS/quotation_pkg_detail.pm new file mode 100644 index 000000000..be3d81529 --- /dev/null +++ b/FS/FS/quotation_pkg_detail.pm @@ -0,0 +1,130 @@ +package FS::quotation_pkg_detail; +use base qw(FS::Record); + +use strict; + +=head1 NAME + +FS::quotation_pkg_detail - Object methods for quotation_pkg_detail records + +=head1 SYNOPSIS + + use FS::quotation_pkg_detail; + + $record = new FS::quotation_pkg_detail \%hash; + $record = new FS::quotation_pkg_detail { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::quotation_pkg_detail object represents additional customer package details +for a quotation. FS::quotation_pkg_detail inherits from FS::Record. The following fields are +currently supported: + +=over 4 + +=item detailnum + +primary key + +=item billpkgnum + +named thusly for quick compatability with L<FS::TemplateItem_Mixin>, +actually the quotationpkgnum for the relevant L<FS::quotation_pkg> + +=item detail + +detail text + +=cut + +# 'format' field isn't used, there for TemplateItem_Mixin + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new record. To add the record 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 { 'quotation_pkg_detail'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=cut + +# the insert method can be inherited from FS::Record + +=item delete + +Delete this record from the database. + +=cut + +# the delete method can be inherited from FS::Record + +=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 + +# the replace method can be inherited from FS::Record + +=item check + +Checks all fields to make sure this is a valid record. 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('detailnum') + || $self->ut_foreign_key('billpkgnum', 'quotation_pkg', 'quotationpkgnum') + || $self->ut_text('detail') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 BUGS + +=head1 SEE ALSO + +L<FS::quotation_pkg>, L<FS::Record> + +=cut + +1; + diff --git a/FS/FS/rate.pm b/FS/FS/rate.pm index d26d11697..9b90dd28b 100644 --- a/FS/FS/rate.pm +++ b/FS/FS/rate.pm @@ -54,6 +54,8 @@ it may still be processed under a different pricing addon package), or be marked as 'skipped', or throw a fatal error, depending on the setting of the 'ignore_unrateable' package option. +Deprecated; we now find the default detail by its lack of regionnum. + =item =back @@ -347,7 +349,11 @@ sub dest_detail { }); } - return $self->default_detail unless $rate_prefix; + if ( !$rate_prefix ) { + # then this call doesn't match any known region; just return the + # appropriate anywhere rate + return $self->default_detail($cdrtypenum) || $self->default_detail(''); + } $regionnum = $rate_prefix->regionnum; @@ -365,7 +371,14 @@ sub dest_detail { %hash, 'cdrtypenum' => $cdrtypenum }); - # find all rates maching ratenum, regionnum and null cdrtypenum + # failing that, return the global default for this plan with the correct + # cdrtypenum (skips weektime processing) + if ( !@details and $cdrtypenum ) { + my $detail = $self->default_detail($cdrtypenum); + return $detail if $detail; + } + # failing that, find all rates maching ratenum, regionnum and null cdrtypenum + # (these can have weektime stuff) if ( !@details and $cdrtypenum ) { @details = qsearch( 'rate_detail', { %hash, @@ -391,7 +404,7 @@ sub dest_detail { return $_ if $_->ratetimenum eq ''; } # if still nothing, return the global default rate for this plan - return $self->default_detail; + return $self->default_detail(''); } =item rate_detail @@ -400,16 +413,24 @@ Returns all region-specific details (see L<FS::rate_detail>) for this rate. =back -=item default_detail +=item default_detail [ CDRTYPENUM ] -Returns the default rate detail, if there is one. +Returns the default rate detail for CDRTYPENUM (or for null CDR type, if not +specified). =cut sub default_detail { my $self = shift; - $self->default_detailnum ? - FS::rate_detail->by_key($self->default_detailnum) : '' + my $cdrtypenum = shift || ''; +# $self->default_detailnum ? +# FS::rate_detail->by_key($self->default_detailnum) : '' + qsearchs( 'rate_detail', { + ratenum => $self->ratenum, + cdrtypenum => $cdrtypenum, + dest_regionnum => '', + orig_regionnum => '', + }) || ''; } =head1 SUBROUTINES diff --git a/FS/FS/rate_detail.pm b/FS/FS/rate_detail.pm index fcd9f58b4..893335401 100644 --- a/FS/FS/rate_detail.pm +++ b/FS/FS/rate_detail.pm @@ -132,6 +132,7 @@ sub check { || $self->ut_foreign_key('ratenum', 'rate', 'ratenum') || $self->ut_foreign_keyn('orig_regionnum', 'rate_region', 'regionnum' ) || $self->ut_foreign_keyn('dest_regionnum', 'rate_region', 'regionnum' ) + || $self->ut_foreign_keyn('cdrtypenum', 'cdr_type', 'cdrtypenum' ) || $self->ut_number('min_included') #|| $self->ut_money('min_charge') @@ -194,6 +195,8 @@ sub dest_regionname { my $self = shift; my $dest_region = $self->dest_region; $dest_region ? $dest_region->regionname : 'Global default'; + # should be 'Anywhere' or something, to indicate that it's the + # cross-region default } =item dest_prefixes_short @@ -230,7 +233,7 @@ associated with this rate plan. sub rate_time_name { my $self = shift; - $self->ratetimenum ? $self->rate_time->ratetimename : '(default)'; + $self->ratetimenum ? $self->rate_time->ratetimename : '(any time)'; } =item classname diff --git a/httemplate/browse/deploy_zone.html b/httemplate/browse/deploy_zone.html index 02ebb8b8c..a1bd57f15 100644 --- a/httemplate/browse/deploy_zone.html +++ b/httemplate/browse/deploy_zone.html @@ -57,7 +57,7 @@ '(cir_speed_down, cir_speed_up)', ], links => [ $link_fixed, $link_fixed, ], - align => 'cllllrr', + align => 'cllllrrr', nohtmlheader => 1, disable_maxselect => 1, disable_total => 1, diff --git a/httemplate/browse/log_email.html b/httemplate/browse/log_email.html new file mode 100644 index 000000000..0f64dd454 --- /dev/null +++ b/httemplate/browse/log_email.html @@ -0,0 +1,92 @@ +<% include('/elements/init_overlib.html') %> +<% include('/browse/elements/browse.html', + 'title' => 'Log email condition configuration', + 'name_singular' => 'condition', + 'html_init' => '<P STYLE="margin-top: 0">' + . $add_condition_link + . ' | ' + . $system_log_link + . '</P>' + . '<SCRIPT>' + . $areyousure + . '</SCRIPT>', + 'query' => $query, + 'count_query' => $count_query, + 'header' => [ '#', + 'Context', + 'Min. Level', + 'Template', + 'To', + '', + ], + 'fields' => [ 'logemailnum', + sub { $_[0]->context || '(all)' }, + sub { $FS::Log::LEVELS[$_[0]->min_level] }, + 'msgname', + 'to_addr', + $actions, + ], + 'sort_fields' => [ 'logemailnum', + 'context', + 'min_level', + 'msgname', + 'to_addr', + '', + ], + 'links' => [ $editlink, + $editlink, + $editlink, + $editlink, + $editlink, + '', + ], + + ) %> + +<%init> + +my $curuser = $FS::CurrentUser::CurrentUser; + +die "access denied" + unless $curuser->access_right([ 'View system logs', 'Configuration' ]); + +my $add_condition_link = include('/elements/popup_link.html', + 'action' => $p.'edit/log_email.html?popup=1', + 'label' => 'Add log email condition', + 'actionlabel' => 'Add log email condition', +); + +my $system_log_link = qq(<A HREF="${p}search/log.html">System Log</A>); + +my $query = { + 'table' => 'log_email', + 'select' => '*', + 'addl_from' => 'LEFT JOIN msg_template USING (msgnum)', + 'hashref' => { }, +}; + +my $count_query = "SELECT COUNT(*) FROM log_email"; + +my $actions = sub { + my $log_email = shift; + my $logemailnum = $log_email->logemailnum; + qq!<A HREF="javascript:areyousure_delete_log_email($logemailnum)">(delete)</A>!; +}; + +my $areyousure_onclick = include('/elements/popup_link_onclick.html', + 'js_action' => q(') . $p . q(misc/delete-log_email.html?logemailnum=' + logemailnum), + 'actionlabel' => 'Delete log email condition', +); + +my $areyousure = <<EOF; +function areyousure_delete_log_email(logemailnum) { + if (confirm('Are you sure you want to delete log email condition #'+logemailnum+'?')) { +${areyousure_onclick} + } +} +EOF + +my $editlink = [ $p.'edit/log_email.html?logemailnum=', 'logemailnum' ]; + +</%init> + diff --git a/httemplate/browse/part_pkg.cgi b/httemplate/browse/part_pkg.cgi index 07f104e55..1e8b51030 100755 --- a/httemplate/browse/part_pkg.cgi +++ b/httemplate/browse/part_pkg.cgi @@ -88,6 +88,14 @@ if ( $cgi->param('missing_recur_fee') ) { )"; } +if ( $cgi->param('ratenum') =~ /^(\d+)$/ ) { + push @where, "EXISTS( SELECT 1 FROM part_pkg_option + WHERE optionname LIKE '%ratenum' + AND optionvalue = '$1' + AND part_pkg_option.pkgpart = part_pkg.pkgpart + )"; +} + if ( $cgi->param('family') =~ /^(\d+)$/ ) { $family_pkgpart = $1; push @where, "family_pkgpart = $1"; diff --git a/httemplate/edit/cust_pkg_detail.html b/httemplate/edit/cust_pkg_detail.html index 5e107066d..b1e60dad5 100644 --- a/httemplate/edit/cust_pkg_detail.html +++ b/httemplate/edit/cust_pkg_detail.html @@ -46,7 +46,7 @@ <TR> <TD></TD> <TD> - <INPUT TYPE="text" NAME="detail<% $row %>" SIZE="60" MAXLENGTH="65" VALUE="<% $_->detail |h %>" rownum="<% $row++ %>" onkeyup = "possiblyAddRow;" > + <INPUT TYPE="text" NAME="detail<% $row %>" SIZE="60" MAXLENGTH="65" VALUE="<% $_->detail |h %>" rownum="<% $row++ %>" onkeyup="possiblyAddRow" onchange="possiblyAddRow" > </TD> </TR> @@ -88,6 +88,7 @@ detail_input.setAttribute('maxLength', 65); detail_input.setAttribute('rownum', rownum); detail_input.onkeyup = possiblyAddRow; + detail_input.onchange = possiblyAddRow; detail_cell.appendChild(detail_input); row.appendChild(detail_cell); diff --git a/httemplate/edit/cust_pkg_discount.html b/httemplate/edit/cust_pkg_discount.html index 0bb84b8f2..e1e3daede 100755 --- a/httemplate/edit/cust_pkg_discount.html +++ b/httemplate/edit/cust_pkg_discount.html @@ -1,18 +1,5 @@ -<% include('/elements/header-popup.html', "Discount Package") %> - -<SCRIPT TYPE="text/javascript"> - - function enable_discount_pkg () { - if ( document.DiscountPkgForm.discountnum.selectedIndex > 0 ) { - document.DiscountPkgForm.submit.disabled = false; - } else { - document.DiscountPkgForm.submit.disabled = true; - } - } - -</SCRIPT> - -<% include('/elements/error.html') %> +<& /elements/header-popup.html, "Discount Package" &> +<& /elements/error.html &> <FORM NAME="DiscountPkgForm" ACTION="<% $p %>edit/process/cust_pkg_discount.html" METHOD=POST> <INPUT TYPE="hidden" NAME="pkgnum" VALUE="<% $pkgnum %>"> @@ -26,17 +13,17 @@ </TD> </TR> -<% include('/elements/tr-select-discount.html', - 'empty_label' => ( $pkgdiscountnum ? '' : 'Select discount' ), - 'onchange' => 'enable_discount_pkg()', - 'cgi' => $cgi, - ) -%> - +<& /elements/tr-select-pkg-discount.html, + curr_value_setup => $setup_discountnum, + curr_value_recur => $recur_discountnum, + disable_setup => $disable_setup, + disable_recur => $disable_recur, +&> + </TABLE> <BR> -<INPUT NAME="submit" TYPE="submit" VALUE="Discount package" <% $pkgdiscountnum ? '' : 'DISABLED' %>> +<INPUT NAME="submit" TYPE="submit" VALUE="Discount package"> </FORM> </BODY> @@ -44,14 +31,13 @@ <%init> -#some false laziness w/misc/change_pkg.cgi - my $conf = new FS::Conf; my $curuser = $FS::CurrentUser::CurrentUser; die "access denied" - unless $curuser->access_right('Discount customer package'); + unless $curuser->access_right([ 'Discount customer package', + 'Waive setup fee']); my $pkgnum = scalar($cgi->param('pkgnum')); $pkgnum =~ /^(\d+)$/ or die "illegal pkgnum $pkgnum"; @@ -67,10 +53,30 @@ my $cust_pkg = 'extra_sql' => ' AND '. $curuser->agentnums_sql, }) or die "unknown pkgnum $pkgnum"; -#my $cust_main = $cust_pkg->cust_main -# or die "can't get cust_main record for custnum ". $cust_pkg->custnum. -# " ( pkgnum ". cust_pkg->pkgnum. ")"; - my $part_pkg = $cust_pkg->part_pkg; +my @discounts = $cust_pkg->cust_pkg_discount_active; +my ($setup_discountnum, $recur_discountnum); +foreach (@discounts) { + if ( $_->setuprecur eq 'setup') { + die "multiple setup discounts on pkg#$pkgnum" if $setup_discountnum; + $setup_discountnum = $_->discountnum; + } elsif ( $_->setuprecur eq 'recur' ) { + die "multiple setup discounts on pkg#$pkgnum" if $recur_discountnum; + $recur_discountnum = $_->discountnum; + } +} +if ( $cust_pkg->waive_setup ) { + $setup_discountnum = -2; +} + +my $disable_setup = 1; +if ( !$cust_pkg->get('setup') and $cust_pkg->base_setup > 0 ) { + $disable_setup = 0; +} +my $disable_recur = 1; +if ( $cust_pkg->base_recur > 0 ) { + $disable_recur = 0; +} + </%init> diff --git a/httemplate/edit/elements/rate_detail.html b/httemplate/edit/elements/rate_detail.html index 7b5ec314a..32dd502ce 100644 --- a/httemplate/edit/elements/rate_detail.html +++ b/httemplate/edit/elements/rate_detail.html @@ -61,18 +61,18 @@ with row headers showing the region name and prefixes. % $row++; % }# foreach @rate_region % if ( !$opt{regionnum} ) { -%# global default +% # global default for this cdrtypenum <TR> <TD COLSPAN=2 STYLE="padding-top: 10px"> <B>Global default</B> (for calls not matching any prefix) </TD> <TD STYLE="padding-top: 10px"> -% # default rate: set a null region +% # default rate: set a null region for this cdr type <B> <& .detail_box, - detail => $rate->default_detail, + detail => $rate->default_detail($cdrtypenum), ratetimenum => '', - cdrtypenum => '', + cdrtypenum => $cdrtypenum, regionnum => '', ratenum => $rate->ratenum &> diff --git a/httemplate/edit/log_email.html b/httemplate/edit/log_email.html new file mode 100644 index 000000000..bbce7c708 --- /dev/null +++ b/httemplate/edit/log_email.html @@ -0,0 +1,45 @@ +<% include( 'elements/edit.html', + 'name_singular' => 'log email condition', + 'table' => 'log_email', + 'fields' => [ + { 'field' => 'context', + 'type' => 'select', + 'options' => [ '', @contexts ], + 'labels' => { '' => '(all)', map { $_ => $_ } @contexts }, + 'curr_value' => scalar($cgi->param('context')), + }, + { 'field' => 'min_level', + 'type' => 'select', + 'options' => [ 0..7 ], + 'labels' => { map {$_ => $FS::Log::LEVELS[$_]} 0..7 }, + 'curr_value' => scalar($cgi->param('min_level')), + }, + 'to_addr', + { 'field' => 'msgnum', + 'type' => 'select-msg_template', + 'empty_label' => 'Select template', + 'required' => 1, + }, + ], + 'labels' => { + 'context' => 'Context', + 'min_level' => 'Min. Level', + 'to_addr' => 'To', + 'msgnum' => 'Message', + }, + 'viewall_dir' => 'browse', + 'popup' => $opts{'popup'}, + 'form_init' => $opts{'popup'} ? q(<INPUT TYPE="hidden" NAME="popup" VALUE="1">) : '', + ) +%> +<%once> +my @contexts = sort FS::log_context->contexts; +</%once> +<%init> + +my %opts = @_; + +die "access denied" + unless $FS::CurrentUser::CurrentUser->access_right([ 'View system logs', 'Configuration' ]); + +</%init> diff --git a/httemplate/edit/msg_template/email.html b/httemplate/edit/msg_template/email.html index dc70ef6ec..b0c1aa31a 100644 --- a/httemplate/edit/msg_template/email.html +++ b/httemplate/edit/msg_template/email.html @@ -302,6 +302,11 @@ my %substitutions = ( '$payinfo' => 'Card/account# (masked)', '$error' => 'Decline reason', ], + 'system_log' => [ + '$logmessage' => 'Log entry message', + '$loglevel' => 'Log entry level', + '$logcontext' => 'Log entry context', + ], ); tie my %sections, 'Tie::IxHash', ( @@ -315,6 +320,7 @@ tie my %sections, 'Tie::IxHash', ( 'svc_domain'=> 'Domain service fields', 'svc_phone' => 'Phone service fields', 'svc_broadband' => 'Broadband service fields', +'system_log' => 'System log fields', ); my $widget = new HTML::Widgets::SelectLayers( diff --git a/httemplate/edit/process/cust_pkg_discount.html b/httemplate/edit/process/cust_pkg_discount.html index 4a71f6975..143611ef9 100644 --- a/httemplate/edit/process/cust_pkg_discount.html +++ b/httemplate/edit/process/cust_pkg_discount.html @@ -14,9 +14,8 @@ <%init> my $curuser = $FS::CurrentUser::CurrentUser; - -die "access denied" - unless $curuser->access_right('Discount customer package'); +my $can_discount = $curuser->access_right('Discount customer package'); +my $can_waive_setup = $curuser->access_right('Waive setup fee'); #this search is really for security wrt agent virt... #maybe move it to the cust_pkg_discount->insert call? @@ -29,20 +28,81 @@ my $cust_pkg = qsearchs({ }); die 'unknown pkgnum' unless $cust_pkg; -my $cust_pkg_discount = new FS::cust_pkg_discount { - 'pkgnum' => $cust_pkg->pkgnum, - 'discountnum' => scalar($cgi->param('discountnum')), - 'months_used' => 0, - 'end_date' => '', #XXX - #for the create a new discount case - '_type' => scalar($cgi->param('discountnum__type')), - 'amount' => scalar($cgi->param('discountnum_amount')), - 'percent' => scalar($cgi->param('discountnum_percent')), - 'months' => scalar($cgi->param('discountnum_months')), - 'setup' => scalar($cgi->param('discountnum_setup')), - #'linked' => scalar($cgi->param('discountnum_linked')), - #'disabled' => $self->discountnum_disabled, -}; -my $error = $cust_pkg_discount->insert; +my $error; +my %discountnum = (setup => '', recur => ''); +if ( $cgi->param('setup_discountnum') == -2 ) { + + die "access denied" unless $can_waive_setup; # UI protects against this + # waive setup fee (not really a discount but treated as one in the UI) + if ( !$cust_pkg->get('setup') and !$cust_pkg->waive_setup ) { + $cust_pkg->set('waive_setup' => 'Y'); + $error = $cust_pkg->replace; + } + +} else { + if ( $cgi->param('setup_discountnum') =~ /^(-?\d+)$/ ) { + $discountnum{setup} = $1; + } + if ( $cust_pkg->waive_setup ) { + $cust_pkg->set('waive_setup', ''); + $error = $cust_pkg->replace; + } +} + +if ( $cgi->param('recur_discountnum') =~ /^(-?\d+)$/ ) { + + $discountnum{recur} = $1; + +} + +my @active_discounts = $cust_pkg->cust_pkg_discount_active; + +foreach my $setuprecur (qw(setup recur)) { + + if ( $cust_pkg->get('setup') and $setuprecur eq 'setup' ) { + # no point allowing setup discounts to be edited for a previously setup + # package + next; + } + + my ($active) = grep { $_->setuprecur eq $setuprecur } @active_discounts; + + if ( $active ) { + if ( $active->discount ne $discountnum{$setuprecur} ) { + $active->set('disabled' => 'Y'); + $error ||= $active->replace; + undef $active; + } else { + # it's the same discountnum; don't touch it + next; + } + } + + if ( $discountnum{$setuprecur} ) { + die "access_denied" unless $can_discount; + my $cust_pkg_discount = FS::cust_pkg_discount->new({ + 'pkgnum' => $cust_pkg->pkgnum, + 'discountnum' => $discountnum{$setuprecur}, + 'setuprecur' => $setuprecur, + 'months_used' => 0, + 'end_date' => '', #XXX + #for the create a new discount case + '_type' => scalar($cgi->param($setuprecur.'_discountnum__type')), + 'amount' => scalar($cgi->param($setuprecur.'_discountnum_amount')), + 'percent' => scalar($cgi->param($setuprecur.'_discountnum_percent')), + }); + if ( $setuprecur eq 'setup' ) { + $cust_pkg_discount->set('setup' => 'Y'); + $cust_pkg_discount->set('months' => 1); + } else { + if ( $cgi->param($setuprecur.'_discountnum_months') =~ /^(\w+)$/ ) { + $cust_pkg_discount->set('months' => $1); + } + } + + $error ||= $cust_pkg_discount->insert; + + } +} # foreach $setuprecur </%init> diff --git a/httemplate/edit/process/elements/process.html b/httemplate/edit/process/elements/process.html index a76f4befb..fd12c61d9 100644 --- a/httemplate/edit/process/elements/process.html +++ b/httemplate/edit/process/elements/process.html @@ -164,7 +164,9 @@ process(); % # some false laziness with the above % my ($form_name, $job_fields) = @{ $opt{'progress_init'} }; <form name="<% $form_name %>"> + <input type="hidden" name="<% $pkey %>" value="<% $new->get($pkey) %>"> % foreach my $field (@$job_fields) { +% next if $field eq $pkey; <input type="hidden" name="<% $field %>" value="<% $cgi->param($field) |h %>"> % } <& /elements/progress-init.html, diff --git a/httemplate/edit/process/log_email.html b/httemplate/edit/process/log_email.html new file mode 100644 index 000000000..769e180a8 --- /dev/null +++ b/httemplate/edit/process/log_email.html @@ -0,0 +1,18 @@ +<% include('elements/process.html', + 'table' => 'log_email', + %processopts + ) %> +<%init> + +my %opts = @_; + +my $curuser = $FS::CurrentUser::CurrentUser; + +die "access denied" + unless $curuser->access_right([ 'View system logs', 'Configuration' ]); + +my %processopts = $opts{'popup'} + ? ( 'popup_reload' => 'Logging email added' ) + : ( 'redirect' => $fsurl.'browse/log_email.html?' ); # id will be needlessly appended, should be harmless + +</%init> diff --git a/httemplate/edit/process/quick-cust_pkg.cgi b/httemplate/edit/process/quick-cust_pkg.cgi index f1d8c2696..60352154a 100644 --- a/httemplate/edit/process/quick-cust_pkg.cgi +++ b/httemplate/edit/process/quick-cust_pkg.cgi @@ -79,9 +79,6 @@ my $contactnum = $1; $cgi->param('locationnum') =~ /^(\-?\d*)$/ or die 'illegal locationnum '. $cgi->param('locationnum'); my $locationnum = $1; -$cgi->param('discountnum') =~ /^(\-?\d*)$/ - or die 'illegal discountnum '. $cgi->param('discountnum'); -my $discountnum = $1; # for going right to a provision service after ordering a package my( $svcpart, $part_svc ) = ( '', '' ); @@ -114,19 +111,29 @@ my %hash = ( 'refnum' => $refnum, 'contactnum' => $contactnum, 'locationnum' => $locationnum, - 'discountnum' => $discountnum, - #for the create a new discount case - 'discountnum__type' => scalar($cgi->param('discountnum__type')), - 'discountnum_amount' => scalar($cgi->param('discountnum_amount')), - 'discountnum_percent' => scalar($cgi->param('discountnum_percent')), - 'discountnum_months' => scalar($cgi->param('discountnum_months')), - 'discountnum_setup' => scalar($cgi->param('discountnum_setup')), 'contract_end' => ( scalar($cgi->param('contract_end')) ? parse_datetime($cgi->param('contract_end')) : '' ), - 'waive_setup' => ( $cgi->param('waive_setup') eq 'Y' ? 'Y' : '' ), ); + +if ( $cgi->param('setup_discountnum') =~ /^(-?\d+)$/ ) { + if ( $1 == -2 ) { + $hash{waive_setup} = 'Y'; + } else { + $hash{setup_discountnum} = $1; + $hash{setup_discountnum_amount} = $cgi->param('setup_discountnum_amount'); + $hash{setup_discountnum_percent} = $cgi->param('setup_discountnum_percent'); + } +} + +if ( $cgi->param('recur_discountnum') =~ /^(-?\d+)$/ ) { + $hash{recur_discountnum} = $1; + $hash{recur_discountnum_amount} = $cgi->param('recur_discountnum_amount'); + $hash{recur_discountnum_percent} = $cgi->param('recur_discountnum_percent'); + $hash{recur_discountnum_months} = $cgi->param('recur_discountnum_months'); +} + $hash{'custnum'} = $cust_main->custnum if $cust_main; if ( $cgi->param('start') eq 'on_hold' ) { diff --git a/httemplate/edit/process/quotation_pkg_detail.html b/httemplate/edit/process/quotation_pkg_detail.html new file mode 100644 index 000000000..2fc420280 --- /dev/null +++ b/httemplate/edit/process/quotation_pkg_detail.html @@ -0,0 +1,45 @@ +% if ( $error ) { +<% header('Error') %> +<FONT COLOR="#ff0000"><B><% $error |h %></B></FONT><BR><BR> +<CENTER><INPUT TYPE="BUTTON" VALUE="OK" onClick="parent.cClick()"></CENTER> +</BODY></HTML> +% } else { +<% header($action) %> + <SCRIPT TYPE="text/javascript"> + window.top.location.reload(); + </SCRIPT> + </BODY></HTML> +% } +<%init> + +my $curuser = $FS::CurrentUser::CurrentUser; + +die "access denied" + unless $curuser->access_right('Generate quotation'); + +$cgi->param('pkgnum') =~ /^(\d+)$/ or die 'illegal pkgnum'; +my $pkgnum = $1; + +my $quotation_pkg = qsearchs({ + 'table' => 'quotation_pkg', + 'addl_from' => 'LEFT JOIN quotation USING ( quotationnum )'. + 'LEFT JOIN cust_main USING ( custnum )', + 'hashref' => { 'quotationpkgnum' => $pkgnum }, + 'extra_sql' => ' AND '. $curuser->agentnums_sql, +}); + +my @orig_details = $quotation_pkg->details(); + +my $action = 'Quotation details'. + ( scalar(@orig_details) ? ' changed ' : ' added ' ); + +my $param = $cgi->Vars; +my @details = (); +for ( my $row = 0; exists($param->{"detail$row"}); $row++ ) { + push @details, $param->{"detail$row"} + if $param->{"detail$row"} =~ /\S/; +} + +my $error = $quotation_pkg->set_details(@details); + +</%init> diff --git a/httemplate/edit/process/rate_detail.html b/httemplate/edit/process/rate_detail.html index f8a744418..4020ce9e2 100644 --- a/httemplate/edit/process/rate_detail.html +++ b/httemplate/edit/process/rate_detail.html @@ -2,7 +2,7 @@ 'table' => 'rate_detail', 'popup_reload' => 'Rate changed', #a popup "parent reload" for now #someday change the individual element and go away instead - 'noerror_callback' => $set_default_detail + #'noerror_callback' => $set_default_detail &> <%init> @@ -12,19 +12,11 @@ die "access denied" my $set_default_detail = sub { my ($cgi, $rate_detail) = @_; - if (!$rate_detail->dest_regionnum) { + if (!$rate_detail->dest_regionnum and !$rate_detail->cdrtypenum) { # then this is a global default rate + # default_detailnum is no longer used, but maintain it anyway (and point + # it at the one with null cdrtypenum) my $rate = $rate_detail->rate; - if ($rate->default_detailnum) { - if ($rate->default_detailnum == $rate_detail->ratedetailnum) { - return; - } else { - # there's somehow an existing default rate. remove it. - my $old_default = $rate->default_detail; - my $error = $old_default->delete; - die "$error (removing old default rate)\n" if $error; - } - } $rate->set('default_detailnum' => $rate_detail->ratedetailnum); my $error = $rate->replace; die "$error (setting default rate)\n" if $error; diff --git a/httemplate/edit/quotation_pkg_detail.html b/httemplate/edit/quotation_pkg_detail.html new file mode 100644 index 000000000..b8f589a9a --- /dev/null +++ b/httemplate/edit/quotation_pkg_detail.html @@ -0,0 +1,116 @@ +<% include("/elements/header-popup.html", $title, '', + ( $cgi->param('error') ? '' : 'onload="addRow()"' ), + ) +%> + +%# <% include('/elements/error.html') %> + +<FORM ACTION="process/quotation_pkg_detail.html" NAME="DetailForm" ID="DetailForm" METHOD="POST"> + +<INPUT TYPE="hidden" NAME="pkgnum" VALUE="<% $pkgnum %>"> + +<TABLE ID="DetailTable" BGCOLOR="#cccccc" BORDER=0 CELLSPACING=1 STYLE="background-color: #cccccc"> + + <TR> + <TD ALIGN="right">Package</TD> + <TD BGCOLOR="#ffffff"><% $part_pkg->pkg %></TD> + </TR> + + <TR> + <TD ALIGN="right">Comment</TD> + <TD BGCOLOR="#ffffff"><% $part_pkg->comment |h %></TD> + </TR> + + <TR> + <TD COLSPAN=2>Detail: </TD> + </TR> + +% my $row = 0; +% for ( @details ) { + + <TR> + <TD></TD> + <TD> + <INPUT TYPE="text" NAME="detail<% $row %>" SIZE="60" MAXLENGTH="65" VALUE="<% $_ |h %>" rownum="<% $row++ %>" onkeyup="possiblyAddRow" onchange="possiblyAddrow"> + </TD> + </TR> + +% } + +</TABLE> + +<BR> +<INPUT TYPE="submit" ID="submit" NAME="submit" VALUE="<% $title %>"> + +</FORM> + +<SCRIPT TYPE="text/javascript"> +% # abject false laziness with edit/cust_pkg_detail.html + + var rownum = <% $row %>; + + function possiblyAddRow() { + if ( ( rownum - this.getAttribute('rownum') ) == 1 ) { + addRow(); + } + } + + function addRow() { + + var table = document.getElementById('DetailTable'); + var tablebody = table.getElementsByTagName('tbody').item(0); + + var row = document.createElement('TR'); + + var empty_cell = document.createElement('TD'); + row.appendChild(empty_cell); + + var detail_cell = document.createElement('TD'); + + var detail_input = document.createElement('INPUT'); + detail_input.setAttribute('name', 'detail'+rownum); + detail_input.setAttribute('id', 'detail'+rownum); + detail_input.setAttribute('size', 60); + detail_input.setAttribute('maxLength', 65); + detail_input.setAttribute('rownum', rownum); + detail_input.onkeyup = possiblyAddRow; + detail_input.onchange = possiblyAddRow; + detail_cell.appendChild(detail_input); + + row.appendChild(detail_cell); + + tablebody.appendChild(row); + + rownum++; + + } + +</SCRIPT> + +</BODY> +</HTML> +<%init> + +my $curuser = $FS::CurrentUser::CurrentUser; + +die "access denied" + unless $curuser->access_right('Generate quotation'); + +$cgi->param('pkgnum') =~ /^(\d+)$/ or die 'illegal pkgnum'; +my $pkgnum = $1; + +my $quotation_pkg = qsearchs({ + 'table' => 'quotation_pkg', + 'addl_from' => 'LEFT JOIN quotation USING ( quotationnum )'. + 'LEFT JOIN cust_main USING ( custnum )', + 'hashref' => { 'quotationpkgnum' => $pkgnum }, + 'extra_sql' => ' AND '. $curuser->agentnums_sql, +}); + +my $part_pkg = $quotation_pkg->part_pkg; + +my @details = $quotation_pkg->details; + +my $title = ( scalar(@details) ? 'Edit ' : 'Add ' ). 'Quotation Details'; + +</%init> diff --git a/httemplate/edit/rate.cgi b/httemplate/edit/rate.cgi index 1b052d62d..5bfc108c1 100644 --- a/httemplate/edit/rate.cgi +++ b/httemplate/edit/rate.cgi @@ -1,7 +1,11 @@ -<% include("/elements/header.html","$action Rate plan", menubar( +<& /elements/header.html, + "$action Rate plan", + menubar( 'View all rate plans' => "${p}browse/rate.cgi", - )) -%> + 'View packages that use this plan' => "${p}browse/part_pkg.cgi?ratenum=" + . $rate->ratenum, + ) +&> <% include('/elements/progress-init.html', 'OneTrueForm', diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html index ea6933198..dcc02c2b1 100644 --- a/httemplate/elements/menu.html +++ b/httemplate/elements/menu.html @@ -787,6 +787,10 @@ $config_misc{'Inventory classes and inventory'} = [ $fsurl.'browse/inventory_cla $config_misc{'Upload targets'} = [ $fsurl.'browse/upload_target.html', 'Billing and payment upload destinations' ] if $curuser->access_right('Configuration'); +$config_misc{'System log emails'} = [ $fsurl.'browse/log_email.html', 'Configure conditions for sending email when logging' ] + if $curuser->access_right('View system logs') + || $curuser->access_right('Configuration'); + tie my %config_menu, 'Tie::IxHash'; if ( $curuser->access_right('Configuration' ) ) { %config_menu = ( diff --git a/httemplate/elements/popup_link.html b/httemplate/elements/popup_link.html index 2b6b187e9..e9728ac1c 100644 --- a/httemplate/elements/popup_link.html +++ b/httemplate/elements/popup_link.html @@ -18,11 +18,13 @@ Example: 'height' => 336, 'color' => '#ff0000', 'closetext' => 'Go Away', # the value '' removes the link + 'title' => 'Hover Text', #uncommon opt 'aname' => "target", # link NAME= value, useful for #targets 'target' => '_parent', 'style' => 'css-attribute:value', + 'html_label' => '<IMG SRC="something.png">', # overrides label } &> @@ -30,6 +32,7 @@ Example: % if ($params->{'action'} && $label) { <A HREF="javascript:void(0);" onClick="<% $onclick |n %>" + <% $params->{'title'} ? 'TITLE="' . $params->{'title'}. '"' : '' |n %> <% $params->{'aname'} ? 'NAME="'. $params->{'aname'}. '"' : '' |n %> <% $params->{'target'} ? 'TARGET="'. $params->{'target'}. '"' : '' |n %> <% $params->{'style'} ? 'STYLE="'. $params->{'style'}. '"' : '' |n %> @@ -48,6 +51,7 @@ if (ref($_[0]) eq 'HASH') { my $label = $params->{'label'}; $label =~ s/ / /g; +$label = $params->{'html_label'} || $label; my $onclick = include('/elements/popup_link_onclick.html', $params); </%init> diff --git a/httemplate/elements/select-months.html b/httemplate/elements/select-months.html new file mode 100644 index 000000000..1cd72fcba --- /dev/null +++ b/httemplate/elements/select-months.html @@ -0,0 +1,11 @@ +<%init> +my %opt = @_; +$opt{id} ||= $opt{field}; # should be the default everywhere +my $max = $opt{max} || 36; +$opt{options} = [ '', 1 .. $max ]; +$opt{labels} = { '' => '', + map { $_ => emt('[quant,_1,month]', $_) } 1 .. $max + }; + +</%init> +<& select.html, %opt &> diff --git a/httemplate/elements/tr-select-months.html b/httemplate/elements/tr-select-months.html index b90ce1ed7..4d8576411 100644 --- a/httemplate/elements/tr-select-months.html +++ b/httemplate/elements/tr-select-months.html @@ -1,11 +1,5 @@ -<%init> -my %opt = @_; -$opt{id} ||= $opt{field}; # should be the default everywhere -my $max = $opt{max} || 36; -$opt{options} = [ '', 1 .. $max ]; -$opt{labels} = { '' => '', - map { $_ => emt('[quant,_1,month]', $_) } 1 .. $max - }; - -</%init> -<& tr-select.html, %opt &> +<& tr-td-label.html, @_ &> + <td> + <& select-months.html, @_ &> + </td> +</tr> diff --git a/httemplate/elements/tr-select-msg_template.html b/httemplate/elements/tr-select-msg_template.html new file mode 100644 index 000000000..1f899e01c --- /dev/null +++ b/httemplate/elements/tr-select-msg_template.html @@ -0,0 +1,12 @@ +<% include('/elements/tr-td-label.html', + 'label' => $opt{'label'} || 'Message template: ', + 'required' => $opt{'required'} ) %> + <TD><% include('select-msg_template.html', %opt) %></TD> +</TR> + +<%init> + +my %opt = @_; + +</%init> + diff --git a/httemplate/elements/tr-select-pkg-discount.html b/httemplate/elements/tr-select-pkg-discount.html new file mode 100644 index 000000000..dc38cff3e --- /dev/null +++ b/httemplate/elements/tr-select-pkg-discount.html @@ -0,0 +1,196 @@ +<%doc> + +In order_pkg.html or similar: + +<& /elements/tr-select-pkg-discount.html, + curr_value_setup => ($cgi->param('setup_discountnum') || ''), + curr_value_recur => ($cgi->param('recur_discountnum') || ''), + disable_setup => 0, + disable_recur => 0, +&> + +This provides the following: +- If the user can waive setup fees or apply a discount, they get a + select box for the setup discount, with "Waive setup fee" as an option. +- If they can custom discount, they will also get "Custom discount" as an + option. If selected, this will show fields to enter the custom discount + amount/percentage. +- If they can waive setup fees but NOT apply a discount, they only get a + checkbox to waive setup fee. +- Same for recurring fee, but without the "waive setup fee" stuff, obviously. +- Custom recurring discounts also have an option for a duration in months. + +"disable_setup" locks the setup discount, but will still show a static +description if curr_value_setup is set. Likewise "disable_recur". + +</%doc> +% # SETUP DISCOUNT + +% # select-discount knows about the "custom discount" ACL +% if ( $curuser->access_right('Discount customer package') +% and !$opt{disable_setup} ) +% { +% my $pre_options = [ '' => '(none)' ]; +% if ( $curuser->access_right('Waive setup fee') ) { +% push @$pre_options, -2 => 'Waive setup fee'; +% } +<& tr-td-label.html, label => emt('Setup fee') &> + <td> + <& select-discount.html, + field => 'setup_discountnum', + id => 'setup_discountnum', + hashref => { disabled => '', + setup => 'Y' + }, + extra_sql => ' AND (percent > 0 OR months = 1)', + curr_value => $opt{'curr_value_setup'}, + disable_empty => 1, + pre_options => $pre_options, + &> + </td> +</tr> +% # custom discount +<tr class="setup_discount_custom"> + <td></td> + <td>Amount <% $money_char %> + <& input-text.html, + field => 'setup_discountnum_amount', + curr_value => ($cgi->param('setup_discountnum_amount') || ''), + size => 5, + &> + or percentage + <& input-text.html, + field => 'setup_discountnum_percent', + curr_value => ($cgi->param('setup_discountnum_percent') || ''), + size => 5, + &> % + </td> +</tr> + +% } elsif ( $curuser->access_right('Waive setup fee') +% and !$opt{disable_setup} ) +% { + +<& tr-td-label.html, label => emt('Waive setup fee') &> + <td> + <& checkbox.html, + field => 'setup_discountnum', + id => 'setup_discountnum', + value => '-2', + curr_value => $opt{'curr_value_setup'}, + &> + </td> +</tr> + +% } elsif ( $opt{'curr_value_setup'} ) { # user can't do anything +% +% my $discount = FS::discount->by_key($opt{'curr_value_setup'}); + + <INPUT TYPE="hidden" NAME="setup_discountnum" VALUE="<% $opt{curr_value_setup} %>"> + + <% $discount->description_short %> + +% } + +% # RECUR DISCOUNT + +% if ( $curuser->access_right('Discount customer package') +% and !$opt{disable_recur} ) { + +<& tr-td-label.html, label => emt('Recurring fee') &> + <td> + <& select-discount.html, + field => 'recur_discountnum', + id => 'recur_discountnum', + hashref => { disabled => '' }, + curr_value => $opt{'curr_value_recur'}, + &> + + </td> +</tr> +% # custom discount +<tr class="recur_discount_custom"> + <td></td> + <td>Amount <% $money_char %> + <& input-text.html, + field => 'recur_discountnum_amount', + curr_value => ($cgi->param('recur_discountnum_amount') || ''), + size => 5, + &> + or percentage + <& input-text.html, + field => 'recur_discountnum_percent', + curr_value => ($cgi->param('recur_discountnum_percent') || ''), + size => 5, + &> % + </td> +</tr> +<tr class="recur_discount_custom"> + <td></td> + <td>Expires after + <& /elements/select-months.html, + field => 'recur_discountnum_months', + curr_value => ($cgi->param('recur_discountnum_months') || ''), + &> + </td> +</tr> + +% } elsif ( $opt{'curr_value_recur'} ) { +% +% my $discount = FS::discount->by_key($opt{'curr_value_recur'}); + + <INPUT TYPE="hidden" NAME="recur_discountnum" VALUE="<% $opt{curr_value_recur} %>"> + + <% $discount->description %> + +% } + +<SCRIPT TYPE="text/javascript"> +$(document).ready(function() { + ['setup', 'recur'].forEach(function(x) { + var discountnum = $('#'+x+'_discountnum'); + + // if it's been set to a custom discount, show custom discount inputs + var discountnum_changed = function() { + var val = this.value; + var custom = $('.'+x+'_discount_custom'); + if ( val == -1 ) { + custom.show(); + } else { + custom.hide(); + } + }; + + discountnum.on('change', discountnum_changed); + discountnum.trigger('change'); + + // if amount contains a value, disable percent, and vice versa + var amount_percent_keyup = function(event) { + var other = event.data; + if (this.value.length > 0) { + other.disabled = true; + } else { + other.disabled = false; + } + }; + var amount = $('#'+x+'_discountnum_amount'); + var percent = $('#'+x+'_discountnum_percent'); + amount.on('keyup', percent, amount_percent_keyup); + percent.on('keyup', amount, amount_percent_keyup); + + amount.trigger('keyup'); + percent.trigger('keyup'); + }); +}); +</script> +<%init> + +my %opt = ( + 'curr_value_setup' => ($cgi->param('setup_discountnum') || ''), + 'curr_value_recur' => ($cgi->param('recur_discountnum') || ''), + @_ +); +my $curuser = $FS::CurrentUser::CurrentUser; +my $money_char = FS::Conf->new->config('money_char') || '$'; + +</%init> diff --git a/httemplate/images/Actions-document-edit-icon.png b/httemplate/images/Actions-document-edit-icon.png Binary files differnew file mode 100644 index 000000000..8bfc32943 --- /dev/null +++ b/httemplate/images/Actions-document-edit-icon.png diff --git a/httemplate/misc/delete-log_email.html b/httemplate/misc/delete-log_email.html new file mode 100644 index 000000000..cc17b15a0 --- /dev/null +++ b/httemplate/misc/delete-log_email.html @@ -0,0 +1,20 @@ +% if ($error) { +<P STYLE="color: red"><% $error %></P> +% } else { +<H1>Log email condition deleted</H1> +<SCRIPT> +window.top.location.reload(); +</SCRIPT> +% } + +<%init> +die "access denied" + unless $FS::CurrentUser::CurrentUser->access_right([ 'View system logs', 'Configuration' ]); + +my $logemailnum = $cgi->param('logemailnum'); +$logemailnum =~ /^\d+$/ or die "bad logemailnum '$logemailnum'"; +my $log_email = FS::log_email->by_key($logemailnum) + or die "logemailnum '$logemailnum' not found"; +my $error = $log_email->delete; +</%init> + diff --git a/httemplate/misc/order_pkg.html b/httemplate/misc/order_pkg.html index cb2bd4832..e28250110 100644 --- a/httemplate/misc/order_pkg.html +++ b/httemplate/misc/order_pkg.html @@ -127,22 +127,7 @@ % if ( $discount_cust_pkg || $waive_setup_fee ) { <FONT CLASS="fsinnerbox-title"><% mt('Discounting') |h %></FONT> <% ntable("#cccccc") %> - -% if ( $waive_setup_fee ) { - <TR> - <TH ALIGN="right"><% mt('Waive setup fee') |h %> </TH> - <TD COLSPAN=6><INPUT TYPE="checkbox" NAME="waive_setup" VALUE="Y"></TD> - </TR> -% } - -% if ( $discount_cust_pkg ) { - <& /elements/tr-select-discount.html, - 'element_etc' => 'DISABLED', - 'colspan' => 7, - 'cgi' => $cgi, - &> -% } - + <& /elements/tr-select-pkg-discount.html &> </TABLE><BR> % } diff --git a/httemplate/search/cust_bill_pkg.cgi b/httemplate/search/cust_bill_pkg.cgi index 278382feb..ab5aad776 100644 --- a/httemplate/search/cust_bill_pkg.cgi +++ b/httemplate/search/cust_bill_pkg.cgi @@ -705,9 +705,19 @@ my $pay_sub = "SELECT SUM(cust_bill_pay_pkg.amount) "; push @select, "($pay_sub) AS pay_amount"; -#total credits -my $credit_sub = 'SELECT SUM(amount) AS credit_amount, billpkgnum - FROM cust_credit_bill_pkg GROUP BY billpkgnum'; +# showing credited amount, optionally with date filtering +my $credit_where = ''; +if ( $cgi->param('credit_begin') or $cgi->param('credit_end') ) { + my($cr_begin, $cr_end) = FS::UI::Web::parse_beginning_ending($cgi, 'credit'); + $credit_where = "WHERE cust_credit_bill._date >= $cr_begin " . + "AND cust_credit_bill._date <= $cr_end"; +} + +my $credit_sub = "SELECT SUM(cust_credit_bill_pkg.amount) AS credit_amount, billpkgnum + FROM cust_credit_bill_pkg + JOIN cust_credit_bill USING (creditbillnum) + $credit_where + GROUP BY billpkgnum"; $join_pkg .= " LEFT JOIN ($credit_sub) AS item_credit ON (cust_bill_pkg.billpkgnum = item_credit.billpkgnum)"; @@ -737,6 +747,10 @@ if ( $cgi->param('salesnum') =~ /^(\d+)$/ ) { $cgi->param('classnum', 0) unless $cgi->param('classnum'); } +#credit flag (include only those that have credit(s) applied) +if ( $cgi->param('credit') ) { + push @where, 'credit_amount > 0'; +} my $where = join(' AND ', @where); $where &&= "WHERE $where"; @@ -775,7 +789,13 @@ my $ilink = [ "${p}view/cust_bill.cgi?", 'invnum' ]; my $clink = [ "${p}view/cust_main.cgi?", 'custnum' ]; my $pay_link = ''; #[, 'billpkgnum', ]; -my $credit_link = [ "${p}search/cust_credit_bill_pkg.html?billpkgnum=", 'billpkgnum', ]; +my $credit_param = ''; +foreach ('credit_begin', 'credit_end') { + if ( $cgi->param($_) ) { + $credit_param .= "$_=" . $cgi->param($_) . ';'; + } +} +my $credit_link = [ "${p}search/cust_credit_bill_pkg.html?${credit_param}billpkgnum=", 'billpkgnum', ]; warn "\n\nQUERY:\n".Dumper($query)."\n\nCOUNT_QUERY:\n$count_query\n\n" if $cgi->param('debug'); diff --git a/httemplate/search/cust_bill_pkg_discount.html b/httemplate/search/cust_bill_pkg_discount.html index 6da5787a0..c63482827 100644 --- a/httemplate/search/cust_bill_pkg_discount.html +++ b/httemplate/search/cust_bill_pkg_discount.html @@ -20,9 +20,7 @@ sub { $_[0]->cust_pkg_discount->discount->description }, sub { $_[0]->cust_pkg_discount->discount->classname }, sub { sprintf($money_char.'%.2f', shift->amount ) }, - sub { my $m = shift->months; - $m =~ /\./ ? sprintf('%.2f', $m) : $m; - }, + $months_sub, 'pkg',#sub { $_[0]->cust_bill_pkg->cust_pkg->part_pkg->pkg }, 'invnum', sub { time2str('%b %d %Y', shift->_date ) }, @@ -218,4 +216,11 @@ my $clink = [ "${p}view/cust_main.cgi?", 'custnum' ]; my $conf = new FS::Conf; my $money_char = $conf->config('money_char') || '$'; +my $months_sub = sub { + my $cust_bill_pkg_discount = shift; + return 'Setup' + if $cust_bill_pkg_discount->cust_pkg_discount->setuprecur eq 'setup'; + sprintf('%.2f', $cust_bill_pkg_discount->months); +}; + </%init> diff --git a/httemplate/search/cust_credit_bill_pkg.html b/httemplate/search/cust_credit_bill_pkg.html index b5a0ee9f6..5facd4ab3 100644 --- a/httemplate/search/cust_credit_bill_pkg.html +++ b/httemplate/search/cust_credit_bill_pkg.html @@ -3,11 +3,12 @@ 'name_singular' => 'credit application', 'query' => $query, 'count_query' => $count_query, - 'count_addl' => [ $money_char. '%.2f total', ], + 'count_addl' => \@count_addl, 'header' => [ #'#', 'Amount', + 'Tax exempt', #credit 'Date', @@ -26,7 +27,9 @@ ], 'fields' => [ #'creditbillpkgnum', - sub { sprintf($money_char.'%.2f', shift->amount ) }, + sub { sprintf($money_char.'%.2f', shift->amount ) }, + + sub { sprintf($money_char.'%.2f', shift->get('exempt_credited') ) }, sub { time2str('%b %d %Y', shift->get('cust_credit_date') ) }, sub { shift->cust_credit_bill->cust_credit->otaker }, @@ -44,6 +47,7 @@ ], 'sort_fields' => [ 'amount', + 'exempt_credited', 'cust_credit_date', '', #'otaker', '', #reason @@ -61,6 +65,7 @@ '', '', '', + '', @post_desc_null, $ilink, $ilink, @@ -68,11 +73,12 @@ FS::UI::Web::cust_header() ), ], - 'align' => 'rrllll'. + 'align' => 'rrrllll'. $post_desc_align. 'rr'. FS::UI::Web::cust_aligns(), - 'color' => [ + 'color' => [ + '', '', '', '', @@ -91,6 +97,7 @@ '', '', '', + '', @post_desc_null, '', '', @@ -286,7 +293,6 @@ if ( $cgi->param('out') ) { #} push @where, $loc_sql; -warn $loc_sql; } my($title, $name); @@ -369,7 +375,7 @@ if ( $cgi->param('report_group') =~ /^(=|!=) (.*)$/ && $cgi->param('istax') ) { } -push @where, 'cust_bill_pkg.pkgnum != 0' if $cgi->param('nottax'); +push @where, '(cust_bill_pkg.pkgnum != 0 OR cust_bill_pkg.feepart is not null)' if $cgi->param('nottax'); push @where, 'cust_bill_pkg.pkgnum = 0' if $cgi->param('istax'); if ( $cgi->param('cust_tax') ) { @@ -393,6 +399,9 @@ if ( $cgi->param('cust_tax') ) { my $count_query = "SELECT COUNT(DISTINCT creditbillpkgnum), SUM(cust_credit_bill_pkg.amount)"; +if ( $cgi->param('nottax') ) { + $count_query .= ", SUM(exempt_credited)"; +} my $join_cust = ' JOIN cust_bill ON ( cust_bill_pkg.invnum = cust_bill.invnum )'. @@ -405,6 +414,21 @@ my $join_cust_bill_pkg = 'LEFT JOIN cust_bill_pkg USING ( billpkgnum )'; if ( $cgi->param('nottax') ) { + # There can be multiple cust_tax_exempt_pkg records with the same + # creditbillpkgnum iff the line item is exempt from multiple taxes. + # They will all have the same amount, except in the case where there are + # different exemption types and so the exemption amounts are different. + # In that case, show the amount of the largest exemption. + + $join_cust_bill_pkg .= ' + LEFT JOIN( + SELECT creditbillpkgnum, + MAX(0 - cust_tax_exempt_pkg.amount) AS exempt_credited + FROM cust_tax_exempt_pkg + WHERE creditbillpkgnum IS NOT NULL + GROUP BY creditbillpkgnum + ) AS exempt_credit USING (creditbillpkgnum) + '; $join_pkg = ' LEFT JOIN cust_pkg USING ( pkgnum ) LEFT JOIN part_pkg USING ( pkgpart ) LEFT JOIN part_pkg AS override @@ -472,6 +496,12 @@ push @select, 'part_pkg.pkg' unless $cgi->param('istax'); push @select, 'cust_main.custnum', FS::UI::Web::cust_sql_fields(); +if ( $cgi->param('istax') ) { + push @select, 'NULL AS exempt_credited'; # just display zero +} elsif ( $cgi->param('nottax') ) { + push @select, 'exempt_credited'; +} + my @post_desc_header = (); my @post_desc = (); my @post_desc_null = (); @@ -555,4 +585,13 @@ my $location_sub = sub { }; +my @count_addl = ( $money_char. '%.2f total', ); +if ( $cgi->param('nottax') ) { + push @count_addl, ( $money_char. '%.2f tax exempt' ); +} + +if ( $cgi->param('debug') ) { + warn "\nQUERY:\n" . Dumper($query) . "\nCOUNT_QUERY:\n$count_query\n\n"; +} + </%init> diff --git a/httemplate/search/cust_pkg_discount.html b/httemplate/search/cust_pkg_discount.html index f0c7447a5..ab6ad2bd0 100644 --- a/httemplate/search/cust_pkg_discount.html +++ b/httemplate/search/cust_pkg_discount.html @@ -18,9 +18,7 @@ sub { ucfirst( shift->status ) }, sub { shift->discount->description }, sub { shift->discount->classname }, - sub { my $m = shift->months_used; - $m =~ /\./ ? sprintf('%.2f',$m) : $m; - }, + $months_used_sub, 'otaker', 'pkg', \&FS::UI::Web::cust_fields, @@ -165,4 +163,9 @@ my $clink = [ "${p}view/cust_main.cgi?", 'custnum' ]; my $conf = new FS::Conf; +my $months_used_sub = sub { + my $cust_pkg_discount = shift; + return 'Setup only' if $cust_pkg_discount->setuprecur eq 'setup'; + return sprintf('%.2f', $cust_pkg_discount->months_used); +}; </%init> diff --git a/httemplate/search/log.html b/httemplate/search/log.html index d1bfb6cc9..a707928d7 100644 --- a/httemplate/search/log.html +++ b/httemplate/search/log.html @@ -1,6 +1,7 @@ <& elements/search.html, 'title' => 'System Log', 'name_singular' => 'event', + 'menubar' => \@menubar, 'html_init' => include('.head'), 'query' => $query, 'count_query' => $count_query, @@ -204,6 +205,9 @@ my $curuser = $FS::CurrentUser::CurrentUser; die "access denied" unless $curuser->access_right([ 'View system logs', 'Configuration' ]); +my @menubar = (); +push @menubar, qq(<A HREF="${fsurl}browse/log_email.html" STYLE="text-decoration: underline;">Configure conditions for sending email when logging</A>), + $cgi->param('min_level', 0) unless defined($cgi->param('min_level')); $cgi->param('max_level', 7) unless defined($cgi->param('max_level')); diff --git a/httemplate/search/report_tax-xls.cgi b/httemplate/search/report_tax-xls.cgi index 743f14788..773b403f1 100755 --- a/httemplate/search/report_tax-xls.cgi +++ b/httemplate/search/report_tax-xls.cgi @@ -122,7 +122,7 @@ my %default = ( border => 1, ); my @widths = ( #ick - 30, (13) x 5, 3, 7.5, 3, 11, 11, 3, 11, 3, 11 + 30, (13) x 6, 3, 7.5, 3, 11, 11, 3, 11, 3, 11 ); my @format = ( {}, {}, {} ); # white row, gray row, yellow (totals) row @@ -134,29 +134,34 @@ foreach (keys(%formatdef)) { italic => 1, %f); } -my $ws = $workbook->add_worksheet('taxreport'); +my $ws = $workbook->add_worksheet('Sales and Tax'); # main title $ws->merge_range(0, 0, 0, 14, $report->title, $format[0]->{title}); +$ws->set_row(0, 30); # excel position my $x = 0; my $y = 2; my $colhead = $format[0]->{colhead}; # print header -$ws->merge_range($y, 1, $y, 5, 'Sales', $colhead); -$ws->merge_range($y, 6, $y+1, 8, 'Rate', $colhead); -$ws->merge_range($y, 9, $y, 15, 'Tax', $colhead); +$ws->merge_range($y, 1, $y, 6, 'Sales', $colhead); +$ws->merge_range($y, 7, $y+1, 9, 'Rate', $colhead); +$ws->merge_range($y, 10, $y, 16, 'Tax', $colhead); $y++; $colhead = $format[0]->{colhead_small}; -$ws->write($y, 1, [ 'Total', 'Exempt customer', 'Exempt package', 'Monthly exemption', +$ws->write($y, 1, [ 'Total', + 'Exempt customer', + 'Exempt package', + 'Monthly exemption', + 'Credited', 'Taxable' ], $colhead); -$ws->write($y, 9, 'Estimated', $colhead); -$ws->write($y, 10, 'Invoiced', $colhead); -$ws->write($y, 12, 'Credited', $colhead); -$ws->write($y, 14, 'Net due', $colhead); -$ws->write($y, 15, 'Collected',$colhead); +$ws->write($y, 10, 'Estimated', $colhead); +$ws->write($y, 11, 'Invoiced', $colhead); +$ws->write($y, 13, 'Credited', $colhead); +$ws->write($y, 15, 'Net due', $colhead); +$ws->write($y, 16, 'Collected',$colhead); $y++; # print data @@ -168,7 +173,7 @@ foreach my $row (@rows) { if ( $row->{pkgclass} ne $prev_row->{pkgclass} ) { $rownum = 1; if ( $params{breakdown}->{pkgclass} ) { - $ws->merge_range($y, 0, $y, 14, + $ws->merge_range($y, 0, $y, 15, $pkgclass_name{$row->{pkgclass}}, $format[0]->{sectionhead} ); @@ -182,7 +187,7 @@ foreach my $row (@rows) { } $ws->write($y, $x, $row->{label}, $f->{rowhead}); $x++; - foreach (qw(sales exempt_cust exempt_pkg exempt_monthly taxable)) { + foreach (qw(sales exempt_cust exempt_pkg exempt_monthly sales_credited taxable)) { $ws->write($y, $x, $row->{$_} || 0, $f->{currency}); $x++; } @@ -229,6 +234,69 @@ for my $x (0..scalar(@widths)-1) { $ws->set_column($x, $x, $widths[$x]); } +# do the same for the credit worksheet +$ws = $workbook->add_worksheet('Credits'); + +my $title = $report->title; +$title =~ s/Tax Report/Credits/; +# main title +$ws->merge_range(0, 0, 0, 14, $title, $format[0]->{title}); +$ws->set_row(0, 30); # height +# excel position +$x = 0; +$y = 2; + +$colhead = $format[0]->{colhead}; +# print header +$ws->merge_range($y, 1, $y+1, 1, 'Total', $colhead); +$ws->merge_range($y, 2, $y, 4, 'Applied to', $colhead); + +$y++; +$colhead = $format[0]->{colhead_small}; +$ws->write($y, 2, [ 'Taxable sales', + 'Tax-exempt sales', + 'Taxes' + ], $colhead); +$y++; + +# print data +$rownum = 1; +$prev_row = { pkgclass => 'DUMMY PKGCLASS' }; + +foreach my $row (@rows) { + $x = 0; + if ( $row->{pkgclass} ne $prev_row->{pkgclass} ) { + $rownum = 1; + if ( $params{breakdown}->{pkgclass} ) { + $ws->merge_range($y, 0, $y, 4, + $pkgclass_name{$row->{pkgclass}}, + $format[0]->{sectionhead} + ); + $y++; + } + } + # pick a format set + my $f = $format[$rownum % 2]; + if ( $row->{total} ) { + $f = $format[2]; + } + $ws->write($y, $x, $row->{label}, $f->{rowhead}); + $x++; + foreach (qw(credits sales_credited exempt_credited tax_credited)) { + $ws->write($y, $x, $row->{$_} || 0, $f->{currency}); + $x++; + } + + $rownum++; + $y++; + $prev_row = $row; +} + +for my $x (0..4) { + $ws->set_column($x, $x, $widths[$x]); +} + + $workbook->close; http_header('Content-Length' => length($data)); diff --git a/httemplate/search/report_tax.cgi b/httemplate/search/report_tax.cgi index 0ad143f01..9e625c80f 100644 --- a/httemplate/search/report_tax.cgi +++ b/httemplate/search/report_tax.cgi @@ -18,6 +18,7 @@ TD.rowhead { font-weight: bold; text-align: left; padding: 0px 3px } .bigmath { font-size: large; font-weight: bold; font: sans-serif; text-align: center } .total { font-style: italic } </STYLE> + <& /elements/table-grid.html &> <THEAD> <TR> @@ -77,18 +78,18 @@ TD.rowhead { font-weight: bold; text-align: left; padding: 0px 3px } % # cust_bill_pkg.cgi wants a list of specific taxnums (and package class) % # cust_credit_bill_pkg.html wants a geographic scope (and package class) % my $rowlink = ';taxnum=' . $row->{taxnums}; -% my $rowregion = ';country=' . $cgi->param('country'); -% foreach my $loc (qw(state county city district)) { -% if ( $row->{$loc} ) { -% $rowregion .= ";$loc=" . uri_escape($row->{$loc}); -% } -% } +% # DON'T EVER USE THIS +% # my $rowregion = ';country=' . $cgi->param('country'); +% # foreach my $loc (qw(state county city district)) { +% # if ( $row->{$loc} ) { +% # $rowregion .= ";$loc=" . uri_escape($row->{$loc}); +% # } +% # } % # and also the package class, if we're limiting package class % if ( $params{breakdown}->{pkgclass} ) { % $rowlink .= ';classnum=' . ($row->{pkgclass} || 0); -% $rowregion .= ';classnum=' . ($row->{pkgclass} || 0); +% # $rowregion .= ';classnum=' . ($row->{pkgclass} || 0); % } -%warn $rowregion; % % if ( $row->{total} ) { </TBODY><TBODY CLASS="total"> @@ -122,7 +123,7 @@ TD.rowhead { font-weight: bold; text-align: left; padding: 0px 3px } </TD> % # credited sales <TD> - <A HREF="<% $salescreditlink . $rowregion %>"> + <A HREF="<% $salescreditlink . $rowlink %>"> <% $money_sprintf->( $row->{sales_credited} ) %> </A> </TD> @@ -183,6 +184,80 @@ TD.rowhead { font-weight: bold; text-align: left; padding: 0px 3px } % } </TABLE> +<BR> +<& /elements/table-grid.html &> + <THEAD> + <TR> + <TH ROwSPAN=2></TH> + <TH ROWSPAN=2>Total credits</TH> + <TH COLSPAN=3>Applied to</TH> + </TR> + <TR STYLE="font-size: small"> + <TH>Taxable sales</TH> + <TH>Tax-exempt sales</TH> + <TH>Taxes</TH> + </TR> + </THEAD> + +% $rownum = 0; +% $prev_row = { pkgclass => 'DUMMY PKGCLASS' }; + + <TBODY> +% # mostly duplicates the stuff above... +% # but putting it all in one giant table is no good +% foreach my $row (@rows) { +% if ( $row->{pkgclass} ne $prev_row->{pkgclass} ) { +% if ( $rownum > 0 ) { # start a new section +% $rownum = 0; + </TBODY><TBODY> +% } +% if ( $params{breakdown}->{pkgclass} ) { # and caption the new section + <TR> + <TD COLSPAN=5 CLASS="sectionhead"> + <% $pkgclass_name{$row->{pkgclass}} %> + </TD> + </TR> +% } +% } # if $row->{pkgclass} ne ... + +% my $rowlink = ';taxnum=' . $row->{taxnums}; +% +% if ( $row->{total} ) { + </TBODY><TBODY CLASS="total"> +% } + <TR CLASS="row<% $rownum % 2 %>"> + <TD CLASS="rowhead"><% $row->{label} |h %></TD> + <TD> +% # Total credits + <% $money_sprintf->( $row->{credits} ) %> + </TD> +% # Credits to taxable sales + <TD> + <A HREF="<% $salescreditlink . $rowlink %>"> + <% $money_sprintf->( $row->{sales_credited} ) %> + </A> + </TD> +% # ... to exempt sales (link is the same, it shows both exempt and taxable) + <TD> + <A HREF="<% $salescreditlink . $rowlink %>"> + <% $money_sprintf->( $row->{exempt_credited} ) %> + </A> + </TD> +% # ... to taxes + <TD> +%# <A HREF="<% $creditlink . $rowlink %>"> currently broken + <% $money_sprintf->( $row->{tax_credited} ) %> +%# </A> + </TD> + </TR> +% $rownum++; +% $prev_row = $row; +% } # foreach my $row +% # no "out of taxable region" for credits (yet) + </TBODY> +</TABLE> + + <& /elements/footer.html &> <%init> @@ -240,10 +315,11 @@ if ( $params{agentnum} ) { my $saleslink = $p. "search/cust_bill_pkg.cgi?$dateagentlink;nottax=1"; my $taxlink = $p. "search/cust_bill_pkg.cgi?$dateagentlink;istax=1"; my $exemptlink = $p. "search/cust_tax_exempt_pkg.cgi?$dateagentlink"; -my $salescreditlink = $p. "search/cust_credit_bill_pkg.html?$dateagentlink;nottax=1"; +my $salescreditlink = $p. "search/cust_bill_pkg.cgi?$dateagentlink;nottax=1;credit=1"; if ( $params{'credit_date'} eq 'cust_credit_bill' ) { $salescreditlink =~ s/begin/credit_begin/; $salescreditlink =~ s/end/credit_end/; + $saleslink .= ";credit_begin=$beginning;credit_end=$ending"; } #my $creditlink = $p. "search/cust_bill_pkg.cgi?$dateagentlink;credit=1;istax=1"; #if ( $params{'credit_date'} eq 'cust_credit_bill' ) { diff --git a/httemplate/view/cust_main/packages.html b/httemplate/view/cust_main/packages.html index 4903e185b..0c67843d4 100755 --- a/httemplate/view/cust_main/packages.html +++ b/httemplate/view/cust_main/packages.html @@ -42,6 +42,7 @@ table.hiddenrows { z-index: 1; text-align: center; } + </STYLE> % # activate rolldown buttons for hidden package blocks <SCRIPT TYPE="text/javascript"> diff --git a/httemplate/view/cust_main/packages/package.html b/httemplate/view/cust_main/packages/package.html index 8aa64039c..e98b95e1c 100644 --- a/httemplate/view/cust_main/packages/package.html +++ b/httemplate/view/cust_main/packages/package.html @@ -53,10 +53,7 @@ ( <%onetime_change_link($cust_pkg)%> ) % } % # also, you can discount it -% if ( $curuser->access_right('Discount customer package') -% && ! scalar($cust_pkg->cust_pkg_discount_active) -% && ! scalar($cust_pkg->part_pkg->part_pkg_discount) -% ) { +% if ( $can_discount_pkg ) { ( <%pkg_discount_link($cust_pkg)%> ) % } <BR> @@ -79,14 +76,7 @@ ( <%pkg_change_link($cust_pkg)%> ) % } % -% if ( $curuser->access_right('Discount customer package') -% && $part_pkg->can_discount -% && ! scalar( @{ $cust_pkg->{_cust_pkg_discount_active} } ) -% && ( ! $opt{'term_discounts'} -% || ! scalar($cust_pkg->part_pkg->part_pkg_discount) -% ) -% ) -% { +% if ( $can_discount_pkg ) { % $br=1; ( <%pkg_discount_link($cust_pkg)%> ) % } @@ -437,4 +427,21 @@ sub pkg_event_link { '</a>'; } +# figure out if this user will be able to edit either the setup or recurring +# discounts for this package +my $can_discount_pkg = ( + $part_pkg->can_discount + and + ( ( $curuser->access_right(['Discount customer package', 'Waive setup fee']) + and $cust_pkg->base_setup > 0 + and !$cust_pkg->setup + ) + or + ( $curuser->access_right('Discount customer package') + and $cust_pkg->base_recur > 0 + and $cust_pkg->freq ne '0' + ) + ) +); + </%init> diff --git a/httemplate/view/cust_main/packages/status.html b/httemplate/view/cust_main/packages/status.html index 7e125f72e..13bd2024b 100644 --- a/httemplate/view/cust_main/packages/status.html +++ b/httemplate/view/cust_main/packages/status.html @@ -461,10 +461,11 @@ sub pkg_status_row_changed { my $part_pkg = $old->part_pkg; $html .= pkg_status_row_colspan( $cust_pkg, - emt("Changed from [_1]: [_2]", - $cust_pkg->change_pkgnum, - $part_pkg->pkg_comment(cust_pkg=>$old, nopartpkg=>1) - ), +# emt("Changed from [_1]: [_2]", +# $cust_pkg->change_pkgnum, +# $part_pkg->pkg_comment(cust_pkg=>$old, nopartpkg=>1) +# ), + '', '', 'size' => '-1', 'align' => 'right', @@ -529,17 +530,25 @@ sub pkg_status_row_discount { my $discount = $cust_pkg_discount->discount; - my $label = '<B>'.emt('Discount').'</B>: '. $discount->description; - if ( $discount->months ) { + my $label = '<SPAN STYLE="font-size: small"><B>'; + if ( $cust_pkg_discount->setuprecur eq 'setup' ) { + $label .= emt('Setup Discount'); + } else { + $label .= emt('Recurring Discount'); + } + $label .= '</B>: '. $discount->description; + warn Dumper $cust_pkg_discount; + if ( $discount->months > 0 and $cust_pkg_discount->months_used > 0 ) { my $remaining = $discount->months - $cust_pkg_discount->months_used; $remaining = sprintf('%.2f', $remaining) if $remaining =~ /\./; - $label .= emt("([_1] months remaining)",$remaining); + $label .= <br> . emt("([_1] months remaining)",$remaining); } + $label .= '</SPAN>'; - $label .= ' <FONT SIZE="-1">('. - '<A HREF="../misc/delete-cust_pkg_discount.html?'. - $cust_pkg_discount->pkgdiscountnum. - '">'.emt('remove discount').'</A>)</FONT>'; + #$label .= ' <FONT SIZE="-1">('. + # '<A HREF="../misc/delete-cust_pkg_discount.html?'. + # $cust_pkg_discount->pkgdiscountnum. + # '">'.emt('remove discount').'</A>)</FONT>'; $html .= pkg_status_row_colspan( $cust_pkg, $label, '', %opt ); diff --git a/httemplate/view/quotation.html b/httemplate/view/quotation.html index 67609a1c6..18625095e 100755 --- a/httemplate/view/quotation.html +++ b/httemplate/view/quotation.html @@ -91,6 +91,8 @@ my $curuser = $FS::CurrentUser::CurrentUser; #die "access denied" # unless $curuser->access_right('View quotations'); +my $can_generate_quotation = $curuser->access_right('Generate quotation'); + my $quotationnum; my($query) = $cgi->keywords; if ( $query =~ /^(\d+)$/ ) { @@ -119,11 +121,23 @@ my $link = "quotationnum=$quotationnum"; #$link .= ';notice_name='. $notice_name if $notice_name; my $preref_callback = sub { - areyousure_link("${p}misc/delete-quotation_pkg.html?". shift->quotationpkgnum, + my $quotation_pkg = shift; + $can_generate_quotation ? + areyousure_link("${p}misc/delete-quotation_pkg.html?". $quotation_pkg->quotationpkgnum, emt('Are you sure you want to remove this package from the quotation?'), emt('Remove this package'), #tooltip qq(<img src="${p}images/cross.png">), #link - ); + ) . + include('/elements/popup_link.html', + action => "${p}edit/quotation_pkg_detail.html?pkgnum=" . + $quotation_pkg->quotationpkgnum, + html_label => qq(<IMG SRC="${p}images/Actions-document-edit-icon.png">), + title => emt('Edit quotation details'), + actionlabel => emt('Edit quotation details'), + color => '#333399', + width => 763, + ) + : ''; }; sub areyousure_link { diff --git a/rt/FREESIDE_MODIFIED b/rt/FREESIDE_MODIFIED index ace0d499b..05ffb2a46 100644 --- a/rt/FREESIDE_MODIFIED +++ b/rt/FREESIDE_MODIFIED @@ -165,3 +165,9 @@ share/html/Search/Elements/PickBasics lib/RT/CustomField.pm share/html/Admin/CustomFields/Modify.html share/html/Ticket/Create.html + +#allow RedirectToBasics to be set from schedule-appointments, RT#38481 +share/html/Search/Schedule.html +share/html/Elements/CalendarSlotSchedule +share/html/Ticket/Display.html + diff --git a/rt/etc/RT_SiteConfig.pm b/rt/etc/RT_SiteConfig.pm index daf136399..0e9823808 100644 --- a/rt/etc/RT_SiteConfig.pm +++ b/rt/etc/RT_SiteConfig.pm @@ -25,9 +25,9 @@ Set($Organization, '%%%RT_DOMAIN%%%'); Set($Timezone, '%%%RT_TIMEZONE%%%'); -Set($WebExternalAuth, 1); +Set($WebRemoteUserAuth, 1); Set($WebFallbackToInternal, 1); #no -Set($WebExternalAuto, 1); +Set($WebRemoteUserAutocreate, 1); $RT::URI::freeside::IntegrationType = 'Internal'; $RT::URI::freeside::URL = '%%%FREESIDE_URL%%%'; diff --git a/rt/lib/RT/Action/CreateTickets.pm.orig b/rt/lib/RT/Action/CreateTickets.pm.orig deleted file mode 100644 index 46791de58..000000000 --- a/rt/lib/RT/Action/CreateTickets.pm.orig +++ /dev/null @@ -1,1295 +0,0 @@ -# BEGIN BPS TAGGED BLOCK {{{ -# -# COPYRIGHT: -# -# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC -# <sales@bestpractical.com> -# -# (Except where explicitly superseded by other copyright notices) -# -# -# LICENSE: -# -# This work is made available to you under the terms of Version 2 of -# the GNU General Public License. A copy of that license should have -# been provided with this software, but in any event can be snarfed -# from www.gnu.org. -# -# This work is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA -# 02110-1301 or visit their web page on the internet at -# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html. -# -# -# CONTRIBUTION SUBMISSION POLICY: -# -# (The following paragraph is not intended to limit the rights granted -# to you to modify and distribute this software under the terms of -# the GNU General Public License and is only of importance to you if -# you choose to contribute your changes and enhancements to the -# community by submitting them to Best Practical Solutions, LLC.) -# -# By intentionally submitting any modifications, corrections or -# derivatives to this work, or any other work intended for use with -# Request Tracker, to Best Practical Solutions, LLC, you confirm that -# you are the copyright holder for those contributions and you grant -# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable, -# royalty-free, perpetual, license to use, copy, create derivative -# works based on those contributions, and sublicense and distribute -# those contributions and any derivatives thereof. -# -# END BPS TAGGED BLOCK }}} - -package RT::Action::CreateTickets; -use base 'RT::Action'; - -use strict; -use warnings; - -use MIME::Entity; - -=head1 NAME - -RT::Action::CreateTickets - Create one or more tickets according to an externally supplied template - -=head1 SYNOPSIS - - ===Create-Ticket: codereview - Subject: Code review for {$Tickets{'TOP'}->Subject} - Depended-On-By: TOP - Content: Someone has created a ticket. you should review and approve it, - so they can finish their work - ENDOFCONTENT - -=head1 DESCRIPTION - -The CreateTickets ScripAction allows you to create automated workflows in RT, -creating new tickets in response to actions and conditions from other -tickets. - -=head2 Format - -CreateTickets uses the RT template configured in the scrip as a template -for an ordered set of tickets to create. The basic format is as follows: - - ===Create-Ticket: identifier - Param: Value - Param2: Value - Param3: Value - Content: Blah - blah - blah - ENDOFCONTENT - ===Create-Ticket: id2 - Param: Value - Content: Blah - ENDOFCONTENT - -As shown, you can put one or more C<===Create-Ticket:> sections in -a template. Each C<===Create-Ticket:> section is evaluated as its own -L<Text::Template> object, which means that you can embed snippets -of Perl inside the L<Text::Template> using C<{}> delimiters, but that -such sections absolutely can not span a C<===Create-Ticket:> boundary. - -Note that each C<Value> must come right after the C<Param> on the same -line. The C<Content:> param can extend over multiple lines, but the text -of the first line must start right after C<Content:>. Don't try to start -your C<Content:> section with a newline. - -After each ticket is created, it's stuffed into a hash called C<%Tickets> -making it available during the creation of other tickets during the -same ScripAction. The hash key for each ticket is C<create-[identifier]>, -where C<[identifier]> is the value you put after C<===Create-Ticket:>. The hash -is prepopulated with the ticket which triggered the ScripAction as -C<$Tickets{'TOP'}>. You can also access that ticket using the shorthand -C<TOP>. - -A simple example: - - ===Create-Ticket: codereview - Subject: Code review for {$Tickets{'TOP'}->Subject} - Depended-On-By: TOP - Content: Someone has created a ticket. you should review and approve it, - so they can finish their work - ENDOFCONTENT - -A convoluted example: - - ===Create-Ticket: approval - { # Find out who the administrators of the group called "HR" - # of which the creator of this ticket is a member - my $name = "HR"; - - my $groups = RT::Groups->new(RT->SystemUser); - $groups->LimitToUserDefinedGroups(); - $groups->Limit(FIELD => "Name", OPERATOR => "=", VALUE => "$name"); - $groups->WithMember($TransactionObj->CreatorObj->Id); - - my $groupid = $groups->First->Id; - - my $adminccs = RT::Users->new(RT->SystemUser); - $adminccs->WhoHaveRight( - Right => "AdminGroup", - Object =>$groups->First, - IncludeSystemRights => undef, - IncludeSuperusers => 0, - IncludeSubgroupMembers => 0, - ); - - our @admins; - while (my $admin = $adminccs->Next) { - push (@admins, $admin->EmailAddress); - } - } - Queue: ___Approvals - Type: approval - AdminCc: {join ("\nAdminCc: ",@admins) } - Depended-On-By: TOP - Refers-To: TOP - Subject: Approval for ticket: {$Tickets{"TOP"}->Id} - {$Tickets{"TOP"}->Subject} - Due: {time + 86400} - Content-Type: text/plain - Content: Your approval is requested for the ticket {$Tickets{"TOP"}->Id}: {$Tickets{"TOP"}->Subject} - Blah - Blah - ENDOFCONTENT - ===Create-Ticket: two - Subject: Manager approval - Type: approval - Depended-On-By: TOP - Refers-To: {$Tickets{"create-approval"}->Id} - Queue: ___Approvals - Content-Type: text/plain - Content: Your approval is requred for this ticket, too. - ENDOFCONTENT - -As shown above, you can include a block with Perl code to set up some -values for the new tickets. If you want to access a variable in the -template section after the block, you must scope it with C<our> rather -than C<my>. Just as with other RT templates, you can also include -Perl code in the template sections using C<{}>. - -=head2 Acceptable Fields - -A complete list of acceptable fields: - - * Queue => Name or id# of a queue - Subject => A text string - ! Status => A valid status. Defaults to 'new' - Due => Dates can be specified in seconds since the epoch - to be handled literally or in a semi-free textual - format which RT will attempt to parse. - Starts => - Started => - Resolved => - Owner => Username or id of an RT user who can and should own - this ticket; forces the owner if necessary - + Requestor => Email address - + Cc => Email address - + AdminCc => Email address - + RequestorGroup => Group name - + CcGroup => Group name - + AdminCcGroup => Group name - TimeWorked => - TimeEstimated => - TimeLeft => - InitialPriority => - FinalPriority => - Type => - +! DependsOn => - +! DependedOnBy => - +! RefersTo => - +! ReferredToBy => - +! Members => - +! MemberOf => - Content => Content. Can extend to multiple lines. Everything - within a template after a Content: header is treated - as content until we hit a line containing only - ENDOFCONTENT - ContentType => the content-type of the Content field. Defaults to - 'text/plain' - UpdateType => 'correspond' or 'comment'; used in conjunction with - 'content' if this is an update. Defaults to - 'correspond' - - CustomField-<id#> => custom field value - CF-name => custom field value - CustomField-name => custom field value - -Fields marked with an C<*> are required. - -Fields marked with a C<+> may have multiple values, simply -by repeating the fieldname on a new line with an additional value. - -Fields marked with a C<!> have processing postponed until after all -tickets in the same actions are created. Except for C<Status>, those -fields can also take a ticket name within the same action (i.e. -the identifiers after C<===Create-Ticket:>), instead of raw ticket ID -numbers. - -When parsed, field names are converted to lowercase and have hyphens stripped. -C<Refers-To>, C<RefersTo>, C<refersto>, C<refers-to> and C<r-e-f-er-s-tO> will -all be treated as the same thing. - -=head1 METHODS - -=cut - -my %LINKTYPEMAP = ( - MemberOf => { - Type => 'MemberOf', - Mode => 'Target', - }, - Parents => { - Type => 'MemberOf', - Mode => 'Target', - }, - Members => { - Type => 'MemberOf', - Mode => 'Base', - }, - Children => { - Type => 'MemberOf', - Mode => 'Base', - }, - HasMember => { - Type => 'MemberOf', - Mode => 'Base', - }, - RefersTo => { - Type => 'RefersTo', - Mode => 'Target', - }, - ReferredToBy => { - Type => 'RefersTo', - Mode => 'Base', - }, - DependsOn => { - Type => 'DependsOn', - Mode => 'Target', - }, - DependedOnBy => { - Type => 'DependsOn', - Mode => 'Base', - }, - -); - - -#Do what we need to do and send it out. -sub Commit { - my $self = shift; - - # Create all the tickets we care about - return (1) unless $self->TicketObj->Type eq 'ticket'; - - $self->CreateByTemplate( $self->TicketObj ); - $self->UpdateByTemplate( $self->TicketObj ); - return (1); -} - - - -sub Prepare { - my $self = shift; - - unless ( $self->TemplateObj ) { - $RT::Logger->warning("No template object handed to $self"); - } - - unless ( $self->TransactionObj ) { - $RT::Logger->warning("No transaction object handed to $self"); - - } - - unless ( $self->TicketObj ) { - $RT::Logger->warning("No ticket object handed to $self"); - - } - - my $active = 0; - if ( $self->TemplateObj->Type eq 'Perl' ) { - $active = 1; - } else { - RT->Logger->info(sprintf( - "Template #%d is type %s. You most likely want to use a Perl template instead.", - $self->TemplateObj->id, $self->TemplateObj->Type - )); - } - - $self->Parse( - Content => $self->TemplateObj->Content, - _ActiveContent => $active, - ); - return 1; - -} - - - -sub CreateByTemplate { - my $self = shift; - my $top = shift; - - $RT::Logger->debug("In CreateByTemplate"); - - my @results; - - # XXX: cargo cult programming that works. i'll be back. - - local %T::Tickets = %T::Tickets; - local $T::TOP = $T::TOP; - local $T::ID = $T::ID; - $T::Tickets{'TOP'} = $T::TOP = $top if $top; - local $T::TransactionObj = $self->TransactionObj; - - my $ticketargs; - my ( @links, @postponed ); - foreach my $template_id ( @{ $self->{'create_tickets'} } ) { - $RT::Logger->debug("Workflow: processing $template_id of $T::TOP") - if $T::TOP; - - $T::ID = $template_id; - @T::AllID = @{ $self->{'create_tickets'} }; - - ( $T::Tickets{$template_id}, $ticketargs ) - = $self->ParseLines( $template_id, \@links, \@postponed ); - - # Now we have a %args to work with. - # Make sure we have at least the minimum set of - # reasonable data and do our thang - - my ( $id, $transid, $msg ) - = $T::Tickets{$template_id}->Create(%$ticketargs); - - foreach my $res ( split( '\n', $msg ) ) { - push @results, - $T::Tickets{$template_id} - ->loc( "Ticket [_1]", $T::Tickets{$template_id}->Id ) . ': ' - . $res; - } - if ( !$id ) { - if ( $self->TicketObj ) { - $msg = "Couldn't create related ticket $template_id for " - . $self->TicketObj->Id . " " - . $msg; - } else { - $msg = "Couldn't create ticket $template_id " . $msg; - } - - $RT::Logger->error($msg); - next; - } - - $RT::Logger->debug("Assigned $template_id with $id"); - $T::Tickets{$template_id}->SetOriginObj( $self->TicketObj ) - if $self->TicketObj - && $T::Tickets{$template_id}->can('SetOriginObj'); - - } - - $self->PostProcess( \@links, \@postponed ); - - return @results; -} - -sub UpdateByTemplate { - my $self = shift; - my $top = shift; - - # XXX: cargo cult programming that works. i'll be back. - - my @results; - local %T::Tickets = %T::Tickets; - local $T::ID = $T::ID; - - my $ticketargs; - my ( @links, @postponed ); - foreach my $template_id ( @{ $self->{'update_tickets'} } ) { - $RT::Logger->debug("Update Workflow: processing $template_id"); - - $T::ID = $template_id; - @T::AllID = @{ $self->{'update_tickets'} }; - - ( $T::Tickets{$template_id}, $ticketargs ) - = $self->ParseLines( $template_id, \@links, \@postponed ); - - # Now we have a %args to work with. - # Make sure we have at least the minimum set of - # reasonable data and do our thang - - my @attribs = qw( - Subject - FinalPriority - Priority - TimeEstimated - TimeWorked - TimeLeft - Status - Queue - Due - Starts - Started - Resolved - ); - - my $id = $template_id; - $id =~ s/update-(\d+).*/$1/; - my ($loaded, $msg) = $T::Tickets{$template_id}->LoadById($id); - - unless ( $loaded ) { - $RT::Logger->error("Couldn't update ticket $template_id: " . $msg); - push @results, $self->loc( "Couldn't load ticket '[_1]'", $id ); - next; - } - - my $current = $self->GetBaseTemplate( $T::Tickets{$template_id} ); - - $template_id =~ m/^update-(.*)/; - my $base_id = "base-$1"; - my $base = $self->{'templates'}->{$base_id}; - if ($base) { - $base =~ s/\r//g; - $base =~ s/\n+$//; - $current =~ s/\n+$//; - - # If we have no base template, set what we can. - if ( $base ne $current ) { - push @results, - "Could not update ticket " - . $T::Tickets{$template_id}->Id - . ": Ticket has changed"; - next; - } - } - push @results, $T::Tickets{$template_id}->Update( - AttributesRef => \@attribs, - ARGSRef => $ticketargs - ); - - if ( $ticketargs->{'Owner'} ) { - ($id, $msg) = $T::Tickets{$template_id}->SetOwner($ticketargs->{'Owner'}, "Force"); - push @results, $msg unless $msg eq $self->loc("That user already owns that ticket"); - } - - push @results, - $self->UpdateWatchers( $T::Tickets{$template_id}, $ticketargs ); - - push @results, - $self->UpdateCustomFields( $T::Tickets{$template_id}, $ticketargs ); - - next unless $ticketargs->{'MIMEObj'}; - if ( $ticketargs->{'UpdateType'} =~ /^(private|comment)$/i ) { - my ( $Transaction, $Description, $Object ) - = $T::Tickets{$template_id}->Comment( - BccMessageTo => $ticketargs->{'Bcc'}, - MIMEObj => $ticketargs->{'MIMEObj'}, - TimeTaken => $ticketargs->{'TimeWorked'} - ); - push( @results, - $T::Tickets{$template_id} - ->loc( "Ticket [_1]", $T::Tickets{$template_id}->id ) - . ': ' - . $Description ); - } elsif ( $ticketargs->{'UpdateType'} =~ /^(public|response|correspond)$/i ) { - my ( $Transaction, $Description, $Object ) - = $T::Tickets{$template_id}->Correspond( - BccMessageTo => $ticketargs->{'Bcc'}, - MIMEObj => $ticketargs->{'MIMEObj'}, - TimeTaken => $ticketargs->{'TimeWorked'} - ); - push( @results, - $T::Tickets{$template_id} - ->loc( "Ticket [_1]", $T::Tickets{$template_id}->id ) - . ': ' - . $Description ); - } else { - push( - @results, - $T::Tickets{$template_id}->loc( - "Update type was neither correspondence nor comment.") - . " " - . $T::Tickets{$template_id}->loc("Update not recorded.") - ); - } - } - - $self->PostProcess( \@links, \@postponed ); - - return @results; -} - -=head2 Parse - -Takes (in order) template content, a default queue, a default requestor, and -active (a boolean flag). - -Parses a template in the template content, defaulting queue and requestor if -unspecified in the template to the values provided as arguments. - -If the active flag is true, then we'll use L<Text::Template> to parse the -templates, allowing you to embed active Perl in your templates. - -=cut - -sub Parse { - my $self = shift; - my %args = ( - Content => undef, - Queue => undef, - Requestor => undef, - _ActiveContent => undef, - @_ - ); - - if ( $args{'_ActiveContent'} ) { - $self->{'UsePerlTextTemplate'} = 1; - } else { - - $self->{'UsePerlTextTemplate'} = 0; - } - - if ( substr( $args{'Content'}, 0, 3 ) eq '===' ) { - $self->_ParseMultilineTemplate(%args); - } elsif ( $args{'Content'} =~ /(?:\t|,)/i ) { - $self->_ParseXSVTemplate(%args); - } else { - RT->Logger->error("Invalid Template Content (Couldn't find ===, and is not a csv/tsv template) - unable to parse: $args{Content}"); - } -} - -=head2 _ParseMultilineTemplate - -Parses mulitline templates. Things like: - - ===Create-Ticket: ... - -Takes the same arguments as L</Parse>. - -=cut - -sub _ParseMultilineTemplate { - my $self = shift; - my %args = (@_); - - my $template_id; - my ( $queue, $requestor ); - $RT::Logger->debug("Line: ==="); - foreach my $line ( split( /\n/, $args{'Content'} ) ) { - $line =~ s/\r$//; - $RT::Logger->debug( "Line: $line" ); - if ( $line =~ /^===/ ) { - if ( $template_id && !$queue && $args{'Queue'} ) { - $self->{'templates'}->{$template_id} - .= "Queue: $args{'Queue'}\n"; - } - if ( $template_id && !$requestor && $args{'Requestor'} ) { - $self->{'templates'}->{$template_id} - .= "Requestor: $args{'Requestor'}\n"; - } - $queue = 0; - $requestor = 0; - } - if ( $line =~ /^===Create-Ticket: (.*)$/ ) { - $template_id = "create-$1"; - $RT::Logger->debug("**** Create ticket: $template_id"); - push @{ $self->{'create_tickets'} }, $template_id; - } elsif ( $line =~ /^===Update-Ticket: (.*)$/ ) { - $template_id = "update-$1"; - $RT::Logger->debug("**** Update ticket: $template_id"); - push @{ $self->{'update_tickets'} }, $template_id; - } elsif ( $line =~ /^===Base-Ticket: (.*)$/ ) { - $template_id = "base-$1"; - $RT::Logger->debug("**** Base ticket: $template_id"); - push @{ $self->{'base_tickets'} }, $template_id; - } elsif ( $line =~ /^===#.*$/ ) { # a comment - next; - } else { - if ( $line =~ /^Queue:(.*)/i ) { - $queue = 1; - my $value = $1; - $value =~ s/^\s//; - $value =~ s/\s$//; - if ( !$value && $args{'Queue'} ) { - $value = $args{'Queue'}; - $line = "Queue: $value"; - } - } - if ( $line =~ /^Requestors?:(.*)/i ) { - $requestor = 1; - my $value = $1; - $value =~ s/^\s//; - $value =~ s/\s$//; - if ( !$value && $args{'Requestor'} ) { - $value = $args{'Requestor'}; - $line = "Requestor: $value"; - } - } - $self->{'templates'}->{$template_id} .= $line . "\n"; - } - } - if ( $template_id && !$queue && $args{'Queue'} ) { - $self->{'templates'}->{$template_id} .= "Queue: $args{'Queue'}\n"; - } - } - -sub ParseLines { - my $self = shift; - my $template_id = shift; - my $links = shift; - my $postponed = shift; - - my $content = $self->{'templates'}->{$template_id}; - - if ( $self->{'UsePerlTextTemplate'} ) { - - $RT::Logger->debug( - "Workflow: evaluating\n$self->{templates}{$template_id}"); - - my $template = Text::Template->new( - TYPE => 'STRING', - SOURCE => $content - ); - - my $err; - $content = $template->fill_in( - PACKAGE => 'T', - BROKEN => sub { - $err = {@_}->{error}; - } - ); - - $RT::Logger->debug("Workflow: yielding $content"); - - if ($err) { - $RT::Logger->error( "Ticket creation failed: " . $err ); - while ( my ( $k, $v ) = each %T::X ) { - $RT::Logger->debug( - "Eliminating $template_id from ${k}'s parents."); - delete $v->{$template_id}; - } - next; - } - } - - my $TicketObj ||= RT::Ticket->new( $self->CurrentUser ); - - my %args; - my %original_tags; - my @lines = ( split( /\n/, $content ) ); - while ( defined( my $line = shift @lines ) ) { - if ( $line =~ /^(.*?):(?:\s+)(.*?)(?:\s*)$/ ) { - my $value = $2; - my $original_tag = $1; - my $tag = lc($original_tag); - $tag =~ s/-//g; - $tag =~ s/^(requestor|cc|admincc)s?$/$1/i; - - $original_tags{$tag} = $original_tag; - - if ( ref( $args{$tag} ) ) - { #If it's an array, we want to push the value - push @{ $args{$tag} }, $value; - } elsif ( defined( $args{$tag} ) ) - { #if we're about to get a second value, make it an array - $args{$tag} = [ $args{$tag}, $value ]; - } else { #if there's nothing there, just set the value - $args{$tag} = $value; - } - - if ( $tag =~ /^content$/i ) { #just build up the content - # convert it to an array - $args{$tag} = defined($value) ? [ $value . "\n" ] : []; - while ( defined( my $l = shift @lines ) ) { - last if ( $l =~ /^ENDOFCONTENT\s*$/ ); - push @{ $args{'content'} }, $l . "\n"; - } - } else { - # if it's not content, strip leading and trailing spaces - if ( $args{$tag} ) { - $args{$tag} =~ s/^\s+//g; - $args{$tag} =~ s/\s+$//g; - } - if ( - ($tag =~ /^(requestor|cc|admincc)(group)?$/i - or grep {lc $_ eq $tag} keys %LINKTYPEMAP) - and $args{$tag} =~ /,/ - ) { - $args{$tag} = [ split /,\s*/, $args{$tag} ]; - } - } - } - } - - foreach my $date (qw(due starts started resolved)) { - my $dateobj = RT::Date->new( $self->CurrentUser ); - next unless $args{$date}; - if ( $args{$date} =~ /^\d+$/ ) { - $dateobj->Set( Format => 'unix', Value => $args{$date} ); - } else { - eval { - $dateobj->Set( Format => 'iso', Value => $args{$date} ); - }; - if ($@ or $dateobj->Unix <= 0) { - $dateobj->Set( Format => 'unknown', Value => $args{$date} ); - } - } - $args{$date} = $dateobj->ISO; - } - - foreach my $role (qw(requestor cc admincc)) { - next unless my $value = $args{ $role . 'group' }; - - my $group = RT::Group->new( $self->CurrentUser ); - $group->LoadUserDefinedGroup( $value ); - unless ( $group->id ) { - $RT::Logger->error("Couldn't load group '$value'"); - next; - } - - $args{ $role } = $args{ $role } ? [$args{ $role }] : [] - unless ref $args{ $role }; - push @{ $args{ $role } }, $group->PrincipalObj->id; - } - - $args{'requestor'} ||= $self->TicketObj->Requestors->MemberEmailAddresses - if $self->TicketObj; - - $args{'type'} ||= 'ticket'; - - my %ticketargs = ( - Queue => $args{'queue'}, - Subject => $args{'subject'}, - Status => $args{'status'} || 'new', - Due => $args{'due'}, - Starts => $args{'starts'}, - Started => $args{'started'}, - Resolved => $args{'resolved'}, - Owner => $args{'owner'}, - Requestor => $args{'requestor'}, - Cc => $args{'cc'}, - AdminCc => $args{'admincc'}, - TimeWorked => $args{'timeworked'}, - TimeEstimated => $args{'timeestimated'}, - TimeLeft => $args{'timeleft'}, - InitialPriority => $args{'initialpriority'} || 0, - FinalPriority => $args{'finalpriority'} || 0, - SquelchMailTo => $args{'squelchmailto'}, - Type => $args{'type'}, - $self->Rules - ); - - if ( $args{content} ) { - my $mimeobj = MIME::Entity->build( - Type => $args{'contenttype'} || 'text/plain', - Charset => 'UTF-8', - Data => [ map {Encode::encode( "UTF-8", $_ )} @{$args{'content'}} ], - ); - $ticketargs{MIMEObj} = $mimeobj; - $ticketargs{UpdateType} = $args{'updatetype'} || 'correspond'; - } - - foreach my $tag ( keys(%args) ) { - # if the tag was added later, skip it - my $orig_tag = $original_tags{$tag} or next; - if ( $orig_tag =~ /^customfield-?(\d+)$/i ) { - $ticketargs{ "CustomField-" . $1 } = $args{$tag}; - } elsif ( $orig_tag =~ /^(?:customfield|cf)-?(.+)$/i ) { - my $cf = RT::CustomField->new( $self->CurrentUser ); - $cf->LoadByName( Name => $1, Queue => $ticketargs{Queue} ); - $cf->LoadByName( Name => $1, Queue => 0 ) unless $cf->id; - next unless $cf->id; - $ticketargs{ "CustomField-" . $cf->id } = $args{$tag}; - } elsif ($orig_tag) { - my $cf = RT::CustomField->new( $self->CurrentUser ); - $cf->LoadByName( Name => $orig_tag, Queue => $ticketargs{Queue} ); - $cf->LoadByName( Name => $orig_tag, Queue => 0 ) unless $cf->id; - next unless $cf->id; - $ticketargs{ "CustomField-" . $cf->id } = $args{$tag}; - - } - } - - $self->GetDeferred( \%args, $template_id, $links, $postponed ); - - return $TicketObj, \%ticketargs; -} - - -=head2 _ParseXSVTemplate - -Parses a tab or comma delimited template. Should only ever be called by -L</Parse>. - -=cut - -sub _ParseXSVTemplate { - my $self = shift; - my %args = (@_); - - use Regexp::Common qw(delimited); - my($first, $content) = split(/\r?\n/, $args{'Content'}, 2); - - my $delimiter; - if ( $first =~ /\t/ ) { - $delimiter = "\t"; - } else { - $delimiter = ','; - } - my @fields = split( /$delimiter/, $first ); - - my $delimiter_re = qr[$delimiter]; - my $justquoted = qr[$RE{quoted}]; - - # Used to generate automatic template ids - my $autoid = 1; - - LINE: - while ($content) { - $content =~ s/^(\s*\r?\n)+//; - - # Keep track of Queue and Requestor, so we can provide defaults - my $queue; - my $requestor; - - # The template for this line - my $template; - - # What column we're on - my $i = 0; - - # If the last iteration was the end of the line - my $EOL = 0; - - # The template id - my $template_id; - - COLUMN: - while (not $EOL and length $content and $content =~ s/^($justquoted|.*?)($delimiter_re|$)//smix) { - $EOL = not $2; - - # Strip off quotes, if they exist - my $value = $1; - if ( $value =~ /^$RE{delimited}{-delim=>qq{\'\"}}$/ ) { - substr( $value, 0, 1 ) = ""; - substr( $value, -1, 1 ) = ""; - } - - # What column is this? - my $field = $fields[$i++]; - next COLUMN unless $field =~ /\S/; - $field =~ s/^\s//; - $field =~ s/\s$//; - - if ( $field =~ /^id$/i ) { - # Special case if this is the ID column - if ( $value =~ /^\d+$/ ) { - $template_id = 'update-' . $value; - push @{ $self->{'update_tickets'} }, $template_id; - } elsif ( $value =~ /^#base-(\d+)$/ ) { - $template_id = 'base-' . $1; - push @{ $self->{'base_tickets'} }, $template_id; - } elsif ( $value =~ /\S/ ) { - $template_id = 'create-' . $value; - push @{ $self->{'create_tickets'} }, $template_id; - } - } else { - # Some translations - if ( $field =~ /^Body$/i - || $field =~ /^Data$/i - || $field =~ /^Message$/i ) - { - $field = 'Content'; - } elsif ( $field =~ /^Summary$/i ) { - $field = 'Subject'; - } elsif ( $field =~ /^Queue$/i ) { - # Note that we found a queue - $queue = 1; - $value ||= $args{'Queue'}; - } elsif ( $field =~ /^Requestors?$/i ) { - $field = 'Requestor'; # Remove plural - # Note that we found a requestor - $requestor = 1; - $value ||= $args{'Requestor'}; - } - - # Tack onto the end of the template - $template .= $field . ": "; - $template .= (defined $value ? $value : ""); - $template .= "\n"; - $template .= "ENDOFCONTENT\n" - if $field =~ /^Content$/i; - } - } - - # Ignore blank lines - next unless $template; - - # If we didn't find a queue of requestor, tack on the defaults - if ( !$queue && $args{'Queue'} ) { - $template .= "Queue: $args{'Queue'}\n"; - } - if ( !$requestor && $args{'Requestor'} ) { - $template .= "Requestor: $args{'Requestor'}\n"; - } - - # If we never found an ID, come up with one - unless ($template_id) { - $autoid++ while exists $self->{'templates'}->{"create-auto-$autoid"}; - $template_id = "create-auto-$autoid"; - # Also, it's a ticket to create - push @{ $self->{'create_tickets'} }, $template_id; - } - - # Save the template we generated - $self->{'templates'}->{$template_id} = $template; - - } -} - -sub GetDeferred { - my $self = shift; - my $args = shift; - my $id = shift; - my $links = shift; - my $postponed = shift; - - # Unify the aliases for child/parent - $args->{$_} = [$args->{$_}] - for grep {$args->{$_} and not ref $args->{$_}} qw/members hasmember memberof/; - push @{$args->{'children'}}, @{delete $args->{'members'}} if $args->{'members'}; - push @{$args->{'children'}}, @{delete $args->{'hasmember'}} if $args->{'hasmember'}; - push @{$args->{'parents'}}, @{delete $args->{'memberof'}} if $args->{'memberof'}; - - # Deferred processing - push @$links, - ( - $id, - { DependsOn => $args->{'dependson'}, - DependedOnBy => $args->{'dependedonby'}, - RefersTo => $args->{'refersto'}, - ReferredToBy => $args->{'referredtoby'}, - Children => $args->{'children'}, - Parents => $args->{'parents'}, - } - ); - - push @$postponed, ( - - # Status is postponed so we don't violate dependencies - $id, { Status => $args->{'status'}, } - ); -} - -sub GetUpdateTemplate { - my $self = shift; - my $t = shift; - - my $string; - $string .= "Queue: " . $t->QueueObj->Name . "\n"; - $string .= "Subject: " . $t->Subject . "\n"; - $string .= "Status: " . $t->Status . "\n"; - $string .= "UpdateType: correspond\n"; - $string .= "Content: \n"; - $string .= "ENDOFCONTENT\n"; - $string .= "Due: " . $t->DueObj->AsString . "\n"; - $string .= "Starts: " . $t->StartsObj->AsString . "\n"; - $string .= "Started: " . $t->StartedObj->AsString . "\n"; - $string .= "Resolved: " . $t->ResolvedObj->AsString . "\n"; - $string .= "Owner: " . $t->OwnerObj->Name . "\n"; - $string .= "Requestor: " . $t->RequestorAddresses . "\n"; - $string .= "Cc: " . $t->CcAddresses . "\n"; - $string .= "AdminCc: " . $t->AdminCcAddresses . "\n"; - $string .= "TimeWorked: " . $t->TimeWorked . "\n"; - $string .= "TimeEstimated: " . $t->TimeEstimated . "\n"; - $string .= "TimeLeft: " . $t->TimeLeft . "\n"; - $string .= "InitialPriority: " . $t->Priority . "\n"; - $string .= "FinalPriority: " . $t->FinalPriority . "\n"; - - foreach my $type ( sort keys %LINKTYPEMAP ) { - - # don't display duplicates - if ( $type eq "HasMember" - || $type eq "Members" - || $type eq "MemberOf" ) - { - next; - } - $string .= "$type: "; - - my $mode = $LINKTYPEMAP{$type}->{Mode}; - my $method = $LINKTYPEMAP{$type}->{Type}; - - my $links = ''; - while ( my $link = $t->$method->Next ) { - $links .= ", " if $links; - - my $object = $mode . "Obj"; - my $member = $link->$object; - $links .= $member->Id if $member; - } - $string .= $links; - $string .= "\n"; - } - - return $string; -} - -sub GetBaseTemplate { - my $self = shift; - my $t = shift; - - my $string; - $string .= "Queue: " . $t->Queue . "\n"; - $string .= "Subject: " . $t->Subject . "\n"; - $string .= "Status: " . $t->Status . "\n"; - $string .= "Due: " . $t->DueObj->Unix . "\n"; - $string .= "Starts: " . $t->StartsObj->Unix . "\n"; - $string .= "Started: " . $t->StartedObj->Unix . "\n"; - $string .= "Resolved: " . $t->ResolvedObj->Unix . "\n"; - $string .= "Owner: " . $t->Owner . "\n"; - $string .= "Requestor: " . $t->RequestorAddresses . "\n"; - $string .= "Cc: " . $t->CcAddresses . "\n"; - $string .= "AdminCc: " . $t->AdminCcAddresses . "\n"; - $string .= "TimeWorked: " . $t->TimeWorked . "\n"; - $string .= "TimeEstimated: " . $t->TimeEstimated . "\n"; - $string .= "TimeLeft: " . $t->TimeLeft . "\n"; - $string .= "InitialPriority: " . $t->Priority . "\n"; - $string .= "FinalPriority: " . $t->FinalPriority . "\n"; - - return $string; -} - -sub GetCreateTemplate { - my $self = shift; - - my $string; - - $string .= "Queue: General\n"; - $string .= "Subject: \n"; - $string .= "Status: new\n"; - $string .= "Content: \n"; - $string .= "ENDOFCONTENT\n"; - $string .= "Due: \n"; - $string .= "Starts: \n"; - $string .= "Started: \n"; - $string .= "Resolved: \n"; - $string .= "Owner: \n"; - $string .= "Requestor: \n"; - $string .= "Cc: \n"; - $string .= "AdminCc:\n"; - $string .= "TimeWorked: \n"; - $string .= "TimeEstimated: \n"; - $string .= "TimeLeft: \n"; - $string .= "InitialPriority: \n"; - $string .= "FinalPriority: \n"; - - foreach my $type ( keys %LINKTYPEMAP ) { - - # don't display duplicates - if ( $type eq "HasMember" - || $type eq 'Members' - || $type eq 'MemberOf' ) - { - next; - } - $string .= "$type: \n"; - } - return $string; -} - -sub UpdateWatchers { - my $self = shift; - my $ticket = shift; - my $args = shift; - - my @results; - - foreach my $type (qw(Requestor Cc AdminCc)) { - my $method = $type . 'Addresses'; - my $oldaddr = $ticket->$method; - - # Skip unless we have a defined field - next unless defined $args->{$type}; - my $newaddr = $args->{$type}; - - my @old = split( /,\s*/, $oldaddr ); - my @new; - for (ref $newaddr ? @{$newaddr} : split( /,\s*/, $newaddr )) { - # Sometimes these are email addresses, sometimes they're - # users. Try to guess which is which, as we want to deal - # with email addresses if at all possible. - if (/^\S+@\S+$/) { - push @new, $_; - } else { - # It doesn't look like an email address. Try to load it. - my $user = RT::User->new($self->CurrentUser); - $user->Load($_); - if ($user->Id) { - push @new, $user->EmailAddress; - } else { - push @new, $_; - } - } - } - - my %oldhash = map { $_ => 1 } @old; - my %newhash = map { $_ => 1 } @new; - - my @add = grep( !defined $oldhash{$_}, @new ); - my @delete = grep( !defined $newhash{$_}, @old ); - - foreach (@add) { - my ( $val, $msg ) = $ticket->AddWatcher( - Type => $type, - Email => $_ - ); - - push @results, - $ticket->loc( "Ticket [_1]", $ticket->Id ) . ': ' . $msg; - } - - foreach (@delete) { - my ( $val, $msg ) = $ticket->DeleteWatcher( - Type => $type, - Email => $_ - ); - push @results, - $ticket->loc( "Ticket [_1]", $ticket->Id ) . ': ' . $msg; - } - } - return @results; -} - -sub UpdateCustomFields { - my $self = shift; - my $ticket = shift; - my $args = shift; - - my @results; - foreach my $arg (keys %{$args}) { - next unless $arg =~ /^CustomField-(\d+)$/; - my $cf = $1; - - my $CustomFieldObj = RT::CustomField->new($self->CurrentUser); - $CustomFieldObj->SetContextObject( $ticket ); - $CustomFieldObj->LoadById($cf); - - my @values; - if ($CustomFieldObj->Type =~ /text/i) { # Both Text and Wikitext - @values = ($args->{$arg}); - } else { - @values = split /\n/, $args->{$arg}; - } - - if ( ($CustomFieldObj->Type eq 'Freeform' - && ! $CustomFieldObj->SingleValue) || - $CustomFieldObj->Type =~ /text/i) { - foreach my $val (@values) { - $val =~ s/\r//g; - } - } - - foreach my $value (@values) { - next unless length($value); - my ( $val, $msg ) = $ticket->AddCustomFieldValue( - Field => $cf, - Value => $value - ); - push ( @results, $msg ); - } - } - return @results; -} - -sub PostProcess { - my $self = shift; - my $links = shift; - my $postponed = shift; - - # postprocessing: add links - - while ( my $template_id = shift(@$links) ) { - my $ticket = $T::Tickets{$template_id}; - $RT::Logger->debug( "Handling links for " . $ticket->Id ); - my %args = %{ shift(@$links) }; - - foreach my $type ( keys %LINKTYPEMAP ) { - next unless ( defined $args{$type} ); - foreach my $link ( - ref( $args{$type} ) ? @{ $args{$type} } : ( $args{$type} ) ) - { - next unless $link; - - if ( $link =~ /^TOP$/i ) { - $RT::Logger->debug( "Building $type link for $link: " - . $T::Tickets{TOP}->Id ); - $link = $T::Tickets{TOP}->Id; - - } elsif ( $link !~ m/^\d+$/ ) { - my $key = "create-$link"; - if ( !exists $T::Tickets{$key} ) { - $RT::Logger->debug( - "Skipping $type link for $key (non-existent)"); - next; - } - $RT::Logger->debug( "Building $type link for $link: " - . $T::Tickets{$key}->Id ); - $link = $T::Tickets{$key}->Id; - } else { - $RT::Logger->debug("Building $type link for $link"); - } - - my ( $wval, $wmsg ) = $ticket->AddLink( - Type => $LINKTYPEMAP{$type}->{'Type'}, - $LINKTYPEMAP{$type}->{'Mode'} => $link, - Silent => 1 - ); - - $RT::Logger->warning("AddLink thru $link failed: $wmsg") - unless $wval; - - # push @non_fatal_errors, $wmsg unless ($wval); - } - - } - } - - # postponed actions -- Status only, currently - while ( my $template_id = shift(@$postponed) ) { - my $ticket = $T::Tickets{$template_id}; - $RT::Logger->debug( "Handling postponed actions for " . $ticket->id ); - my %args = %{ shift(@$postponed) }; - $ticket->SetStatus( $args{Status} ) if defined $args{Status}; - } - -} - -sub Options { - my $self = shift; - my $queues = RT::Queues->new($self->CurrentUser); - $queues->UnLimit; - my @names; - while (my $queue = $queues->Next) { - push @names, $queue->Id, $queue->Name; - } - return ( - { - 'name' => 'Queue', - 'label' => 'In queue', - 'type' => 'select', - 'options' => \@names - } - ) -} - -RT::Base->_ImportOverlays(); - -1; - diff --git a/rt/lib/RT/Action/SendEmail.pm.orig b/rt/lib/RT/Action/SendEmail.pm.orig deleted file mode 100755 index af3a6bf8a..000000000 --- a/rt/lib/RT/Action/SendEmail.pm.orig +++ /dev/null @@ -1,1133 +0,0 @@ -# BEGIN BPS TAGGED BLOCK {{{ -# -# COPYRIGHT: -# -# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC -# <sales@bestpractical.com> -# -# (Except where explicitly superseded by other copyright notices) -# -# -# LICENSE: -# -# This work is made available to you under the terms of Version 2 of -# the GNU General Public License. A copy of that license should have -# been provided with this software, but in any event can be snarfed -# from www.gnu.org. -# -# This work is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA -# 02110-1301 or visit their web page on the internet at -# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html. -# -# -# CONTRIBUTION SUBMISSION POLICY: -# -# (The following paragraph is not intended to limit the rights granted -# to you to modify and distribute this software under the terms of -# the GNU General Public License and is only of importance to you if -# you choose to contribute your changes and enhancements to the -# community by submitting them to Best Practical Solutions, LLC.) -# -# By intentionally submitting any modifications, corrections or -# derivatives to this work, or any other work intended for use with -# Request Tracker, to Best Practical Solutions, LLC, you confirm that -# you are the copyright holder for those contributions and you grant -# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable, -# royalty-free, perpetual, license to use, copy, create derivative -# works based on those contributions, and sublicense and distribute -# those contributions and any derivatives thereof. -# -# END BPS TAGGED BLOCK }}} - -# Portions Copyright 2000 Tobias Brox <tobix@cpan.org> - -package RT::Action::SendEmail; - -use strict; -use warnings; - -use base qw(RT::Action); - -use RT::EmailParser; -use RT::Interface::Email; -use Email::Address; -our @EMAIL_RECIPIENT_HEADERS = qw(To Cc Bcc); - - -=head1 NAME - -RT::Action::SendEmail - An Action which users can use to send mail -or can subclassed for more specialized mail sending behavior. -RT::Action::AutoReply is a good example subclass. - -=head1 SYNOPSIS - - use base 'RT::Action::SendEmail'; - -=head1 DESCRIPTION - -Basically, you create another module RT::Action::YourAction which ISA -RT::Action::SendEmail. - -=head1 METHODS - -=head2 CleanSlate - -Cleans class-wide options, like L</AttachTickets>. - -=cut - -sub CleanSlate { - my $self = shift; - $self->AttachTickets(undef); -} - -=head2 Commit - -Sends the prepared message and writes outgoing record into DB if the feature is -activated in the config. - -=cut - -sub Commit { - my $self = shift; - - return abs $self->SendMessage( $self->TemplateObj->MIMEObj ) - unless RT->Config->Get('RecordOutgoingEmail'); - - $self->DeferDigestRecipients(); - my $message = $self->TemplateObj->MIMEObj; - - my $orig_message; - $orig_message = $message->dup if RT::Interface::Email::WillSignEncrypt( - Attachment => $self->TransactionObj->Attachments->First, - Ticket => $self->TicketObj, - ); - - my ($ret) = $self->SendMessage($message); - return abs( $ret ) if $ret <= 0; - - if ($orig_message) { - $message->attach( - Type => 'application/x-rt-original-message', - Disposition => 'inline', - Data => $orig_message->as_string, - ); - } - $self->RecordOutgoingMailTransaction($message); - $self->RecordDeferredRecipients(); - return 1; -} - -=head2 Prepare - -Builds an outgoing email we're going to send using scrip's template. - -=cut - -sub Prepare { - my $self = shift; - - my ( $result, $message ) = $self->TemplateObj->Parse( - Argument => $self->Argument, - TicketObj => $self->TicketObj, - TransactionObj => $self->TransactionObj - ); - if ( !$result ) { - return (undef); - } - - my $MIMEObj = $self->TemplateObj->MIMEObj; - - # Header - $self->SetRTSpecialHeaders(); - - my %seen; - foreach my $type (@EMAIL_RECIPIENT_HEADERS) { - @{ $self->{$type} } - = grep defined && length && !$seen{ lc $_ }++, - @{ $self->{$type} }; - } - - $self->RemoveInappropriateRecipients(); - - # Go add all the Tos, Ccs and Bccs that we need to to the message to - # make it happy, but only if we actually have values in those arrays. - -# TODO: We should be pulling the recipients out of the template and shove them into To, Cc and Bcc - - for my $header (@EMAIL_RECIPIENT_HEADERS) { - - $self->SetHeader( $header, join( ', ', @{ $self->{$header} } ) ) - if (!$MIMEObj->head->get($header) - && $self->{$header} - && @{ $self->{$header} } ); - } - # PseudoTo (fake to headers) shouldn't get matched for message recipients. - # If we don't have any 'To' header (but do have other recipients), drop in - # the pseudo-to header. - $self->SetHeader( 'To', join( ', ', @{ $self->{'PseudoTo'} } ) ) - if $self->{'PseudoTo'} - && @{ $self->{'PseudoTo'} } - && !$MIMEObj->head->get('To') - && ( $MIMEObj->head->get('Cc') or $MIMEObj->head->get('Bcc') ); - - # We should never have to set the MIME-Version header - $self->SetHeader( 'MIME-Version', '1.0' ); - - # fsck.com #5959: Since RT sends 8bit mail, we should say so. - $self->SetHeader( 'Content-Transfer-Encoding', '8bit' ); - - # For security reasons, we only send out textual mails. - foreach my $part ( grep !$_->is_multipart, $MIMEObj->parts_DFS ) { - my $type = $part->mime_type || 'text/plain'; - $type = 'text/plain' unless RT::I18N::IsTextualContentType($type); - $part->head->mime_attr( "Content-Type" => $type ); - # utf-8 here is for _FindOrGuessCharset in I18N.pm - # it's not the final charset/encoding sent - $part->head->mime_attr( "Content-Type.charset" => 'utf-8' ); - } - - RT::I18N::SetMIMEEntityToEncoding( $MIMEObj, - RT->Config->Get('EmailOutputEncoding'), - 'mime_words_ok', ); - - # Build up a MIME::Entity that looks like the original message. - $self->AddAttachments if ( $MIMEObj->head->get('RT-Attach-Message') - && ( $MIMEObj->head->get('RT-Attach-Message') !~ /^(n|no|0|off|false)$/i ) ); - - $self->AddTickets; - - my $attachment = $self->TransactionObj->Attachments->First; - if ($attachment - && !( - $attachment->GetHeader('X-RT-Encrypt') - || $self->TicketObj->QueueObj->Encrypt - ) - ) - { - $attachment->SetHeader( 'X-RT-Encrypt' => 1 ) - if ( $attachment->GetHeader("X-RT-Incoming-Encryption") || '' ) eq - 'Success'; - } - - return $result; -} - -=head2 To - -Returns an array of L<Email::Address> objects containing all the To: recipients for this notification - -=cut - -sub To { - my $self = shift; - return ( $self->AddressesFromHeader('To') ); -} - -=head2 Cc - -Returns an array of L<Email::Address> objects containing all the Cc: recipients for this notification - -=cut - -sub Cc { - my $self = shift; - return ( $self->AddressesFromHeader('Cc') ); -} - -=head2 Bcc - -Returns an array of L<Email::Address> objects containing all the Bcc: recipients for this notification - -=cut - -sub Bcc { - my $self = shift; - return ( $self->AddressesFromHeader('Bcc') ); - -} - -sub AddressesFromHeader { - my $self = shift; - my $field = shift; - my $header = Encode::decode("UTF-8",$self->TemplateObj->MIMEObj->head->get($field)); - my @addresses = Email::Address->parse($header); - - return (@addresses); -} - -=head2 SendMessage MIMEObj - -sends the message using RT's preferred API. -TODO: Break this out to a separate module - -=cut - -sub SendMessage { - - # DO NOT SHIFT @_ in this subroutine. It breaks Hook::LexWrap's - # ability to pass @_ to a 'post' routine. - my ( $self, $MIMEObj ) = @_; - - my $msgid = Encode::decode( "UTF-8", $MIMEObj->head->get('Message-ID') ); - chomp $msgid; - - $self->ScripActionObj->{_Message_ID}++; - - $RT::Logger->info( $msgid . " #" - . $self->TicketObj->id . "/" - . $self->TransactionObj->id - . " - Scrip " - . ($self->ScripObj->id || '#rule'). " " - . ( $self->ScripObj->Description || '' ) ); - - my $status = RT::Interface::Email::SendEmail( - Entity => $MIMEObj, - Ticket => $self->TicketObj, - Transaction => $self->TransactionObj, - ); - - - return $status unless ($status > 0 || exists $self->{'Deferred'}); - - my $success = $msgid . " sent "; - foreach (@EMAIL_RECIPIENT_HEADERS) { - my $recipients = Encode::decode( "UTF-8", $MIMEObj->head->get($_) ); - $success .= " $_: " . $recipients if $recipients; - } - - if( exists $self->{'Deferred'} ) { - for (qw(daily weekly susp)) { - $success .= "\nBatched email $_ for: ". join(", ", keys %{ $self->{'Deferred'}{ $_ } } ) - if exists $self->{'Deferred'}{ $_ }; - } - } - - $success =~ s/\n//g; - - $RT::Logger->info($success); - - return (1); -} - -=head2 AddAttachments - -Takes any attachments to this transaction and attaches them to the message -we're building. - -=cut - -sub AddAttachments { - my $self = shift; - - my $MIMEObj = $self->TemplateObj->MIMEObj; - - $MIMEObj->head->delete('RT-Attach-Message'); - - my $attachments = RT::Attachments->new( RT->SystemUser ); - $attachments->Limit( - FIELD => 'TransactionId', - VALUE => $self->TransactionObj->Id - ); - - # Don't attach anything blank - $attachments->LimitNotEmpty; - $attachments->OrderBy( FIELD => 'id' ); - - # We want to make sure that we don't include the attachment that's - # being used as the "Content" of this message" unless that attachment's - # content type is not like text/... - my $transaction_content_obj = $self->TransactionObj->ContentObj; - - if ( $transaction_content_obj - && $transaction_content_obj->ContentType =~ m{text/}i ) - { - # If this was part of a multipart/alternative, skip all of the kids - my $parent = $transaction_content_obj->ParentObj; - if ($parent and $parent->Id and $parent->ContentType eq "multipart/alternative") { - $attachments->Limit( - ENTRYAGGREGATOR => 'AND', - FIELD => 'parent', - OPERATOR => '!=', - VALUE => $parent->Id, - ); - } else { - $attachments->Limit( - ENTRYAGGREGATOR => 'AND', - FIELD => 'id', - OPERATOR => '!=', - VALUE => $transaction_content_obj->Id, - ); - } - } - - # attach any of this transaction's attachments - my $seen_attachment = 0; - while ( my $attach = $attachments->Next ) { - if ( !$seen_attachment ) { - $MIMEObj->make_multipart( 'mixed', Force => 1 ); - $seen_attachment = 1; - } - $self->AddAttachment($attach); - } -} - -=head2 AddAttachment $attachment - -Takes one attachment object of L<RT::Attachment> class and attaches it to the message -we're building. - -=cut - -sub AddAttachment { - my $self = shift; - my $attach = shift; - my $MIMEObj = shift || $self->TemplateObj->MIMEObj; - - # $attach->TransactionObj may not always be $self->TransactionObj - return unless $attach->Id - and $attach->TransactionObj->CurrentUserCanSee; - - # ->attach expects just the disposition type; extract it if we have the header - # or default to "attachment" - my $disp = ($attach->GetHeader('Content-Disposition') || '') - =~ /^\s*(inline|attachment)/i ? $1 : "attachment"; - - $MIMEObj->attach( - Type => $attach->ContentType, - Charset => $attach->OriginalEncoding, - Data => $attach->OriginalContent, - Disposition => $disp, - Filename => $self->MIMEEncodeString( $attach->Filename ), - 'RT-Attachment:' => $self->TicketObj->Id . "/" - . $self->TransactionObj->Id . "/" - . $attach->id, - Encoding => '-SUGGEST', - ); -} - -=head2 AttachTickets [@IDs] - -Returns or set list of ticket's IDs that should be attached to an outgoing message. - -B<Note> this method works as a class method and setup things global, so you have to -clean list by passing undef as argument. - -=cut - -{ - my $list = []; - - sub AttachTickets { - my $self = shift; - $list = [ grep defined, @_ ] if @_; - return @$list; - } -} - -=head2 AddTickets - -Attaches tickets to the current message, list of tickets' ids get from -L</AttachTickets> method. - -=cut - -sub AddTickets { - my $self = shift; - $self->AddTicket($_) foreach $self->AttachTickets; - return; -} - -=head2 AddTicket $ID - -Attaches a ticket with ID to the message. - -Each ticket is attached as multipart entity and all its messages and attachments -are attached as sub entities in order of creation, but only if transaction type -is Create or Correspond. - -=cut - -sub AddTicket { - my $self = shift; - my $tid = shift; - - my $attachs = RT::Attachments->new( $self->TransactionObj->CreatorObj ); - my $txn_alias = $attachs->TransactionAlias; - $attachs->Limit( ALIAS => $txn_alias, FIELD => 'Type', VALUE => 'Create' ); - $attachs->Limit( - ALIAS => $txn_alias, - FIELD => 'Type', - VALUE => 'Correspond' - ); - $attachs->LimitByTicket($tid); - $attachs->LimitNotEmpty; - $attachs->OrderBy( FIELD => 'Created' ); - - my $ticket_mime = MIME::Entity->build( - Type => 'multipart/mixed', - Top => 0, - Description => "ticket #$tid", - ); - while ( my $attachment = $attachs->Next ) { - $self->AddAttachment( $attachment, $ticket_mime ); - } - if ( $ticket_mime->parts ) { - my $email_mime = $self->TemplateObj->MIMEObj; - $email_mime->make_multipart; - $email_mime->add_part($ticket_mime); - } - return; -} - -=head2 RecordOutgoingMailTransaction MIMEObj - -Record a transaction in RT with this outgoing message for future record-keeping purposes - -=cut - -sub RecordOutgoingMailTransaction { - my $self = shift; - my $MIMEObj = shift; - - my @parts = $MIMEObj->parts; - my @attachments; - my @keep; - foreach my $part (@parts) { - my $attach = $part->head->get('RT-Attachment'); - if ($attach) { - $RT::Logger->debug( - "We found an attachment. we want to not record it."); - push @attachments, $attach; - } else { - $RT::Logger->debug("We found a part. we want to record it."); - push @keep, $part; - } - } - $MIMEObj->parts( \@keep ); - foreach my $attachment (@attachments) { - $MIMEObj->head->add( 'RT-Attachment', $attachment ); - } - - RT::I18N::SetMIMEEntityToEncoding( $MIMEObj, 'utf-8', 'mime_words_ok' ); - - my $transaction - = RT::Transaction->new( $self->TransactionObj->CurrentUser ); - -# XXX: TODO -> Record attachments as references to things in the attachments table, maybe. - - my $type; - if ( $self->TransactionObj->Type eq 'Comment' ) { - $type = 'CommentEmailRecord'; - } else { - $type = 'EmailRecord'; - } - - my $msgid = Encode::decode( "UTF-8", $MIMEObj->head->get('Message-ID') ); - chomp $msgid; - - my ( $id, $msg ) = $transaction->Create( - Ticket => $self->TicketObj->Id, - Type => $type, - Data => $msgid, - MIMEObj => $MIMEObj, - ActivateScrips => 0 - ); - - if ($id) { - $self->{'OutgoingMailTransaction'} = $id; - } else { - $RT::Logger->warning( - "Could not record outgoing message transaction: $msg"); - } - return $id; -} - -=head2 SetRTSpecialHeaders - -This routine adds all the random headers that RT wants in a mail message -that don't matter much to anybody else. - -=cut - -sub SetRTSpecialHeaders { - my $self = shift; - - $self->SetSubject(); - $self->SetSubjectToken(); - $self->SetHeaderAsEncoding( 'Subject', - RT->Config->Get('EmailOutputEncoding') ) - if ( RT->Config->Get('EmailOutputEncoding') ); - $self->SetReturnAddress(); - $self->SetReferencesHeaders(); - - unless ( $self->TemplateObj->MIMEObj->head->get('Message-ID') ) { - - # Get Message-ID for this txn - my $msgid = ""; - if ( my $msg = $self->TransactionObj->Message->First ) { - $msgid = $msg->GetHeader("RT-Message-ID") - || $msg->GetHeader("Message-ID"); - } - - # If there is one, and we can parse it, then base our Message-ID on it - if ( $msgid - and $msgid - =~ s/<(rt-.*?-\d+-\d+)\.(\d+)-\d+-\d+\@\QRT->Config->Get('Organization')\E>$/ - "<$1." . $self->TicketObj->id - . "-" . $self->ScripObj->id - . "-" . $self->ScripActionObj->{_Message_ID} - . "@" . RT->Config->Get('Organization') . ">"/eg - and $2 == $self->TicketObj->id - ) - { - $self->SetHeader( "Message-ID" => $msgid ); - } else { - $self->SetHeader( - 'Message-ID' => RT::Interface::Email::GenMessageId( - Ticket => $self->TicketObj, - Scrip => $self->ScripObj, - ScripAction => $self->ScripActionObj - ), - ); - } - } - - if (my $precedence = RT->Config->Get('DefaultMailPrecedence') - and !$self->TemplateObj->MIMEObj->head->get("Precedence") - ) { - $self->SetHeader( 'Precedence', $precedence ); - } - - $self->SetHeader( 'X-RT-Loop-Prevention', RT->Config->Get('rtname') ); - $self->SetHeader( 'RT-Ticket', - RT->Config->Get('rtname') . " #" . $self->TicketObj->id() ); - $self->SetHeader( 'Managed-by', - "RT $RT::VERSION (http://www.bestpractical.com/rt/)" ); - -# XXX, TODO: use /ShowUser/ShowUserEntry(or something like that) when it would be -# refactored into user's method. - if ( my $email = $self->TransactionObj->CreatorObj->EmailAddress - and ! defined $self->TemplateObj->MIMEObj->head->get("RT-Originator") - and RT->Config->Get('UseOriginatorHeader') - ) { - $self->SetHeader( 'RT-Originator', $email ); - } - -} - - -sub DeferDigestRecipients { - my $self = shift; - $RT::Logger->debug( "Calling SetRecipientDigests for transaction " . $self->TransactionObj . ", id " . $self->TransactionObj->id ); - - # The digest attribute will be an array of notifications that need to - # be sent for this transaction. The array will have the following - # format for its objects. - # $digest_hash -> {daily|weekly|susp} -> address -> {To|Cc|Bcc} - # -> sent -> {true|false} - # The "sent" flag will be used by the cron job to indicate that it has - # run on this transaction. - # In a perfect world we might move this hash construction to the - # extension module itself. - my $digest_hash = {}; - - foreach my $mailfield (@EMAIL_RECIPIENT_HEADERS) { - # If we have a "PseudoTo", the "To" contains it, so we don't need to access it - next if ( ( $self->{'PseudoTo'} && @{ $self->{'PseudoTo'} } ) && ( $mailfield eq 'To' ) ); - $RT::Logger->debug( "Working on mailfield $mailfield; recipients are " . join( ',', @{ $self->{$mailfield} } ) ); - - # Store the 'daily digest' folk in an array. - my ( @send_now, @daily_digest, @weekly_digest, @suspended ); - - # Have to get the list of addresses directly from the MIME header - # at this point. - $RT::Logger->debug( Encode::decode( "UTF-8", $self->TemplateObj->MIMEObj->head->as_string ) ); - foreach my $rcpt ( map { $_->address } $self->AddressesFromHeader($mailfield) ) { - next unless $rcpt; - my $user_obj = RT::User->new(RT->SystemUser); - $user_obj->LoadByEmail($rcpt); - if ( ! $user_obj->id ) { - # If there's an email address in here without an associated - # RT user, pass it on through. - $RT::Logger->debug( "User $rcpt is not associated with an RT user object. Send mail."); - push( @send_now, $rcpt ); - next; - } - - my $mailpref = RT->Config->Get( 'EmailFrequency', $user_obj ) || ''; - $RT::Logger->debug( "Got user mail preference '$mailpref' for user $rcpt"); - - if ( $mailpref =~ /daily/i ) { push( @daily_digest, $rcpt ) } - elsif ( $mailpref =~ /weekly/i ) { push( @weekly_digest, $rcpt ) } - elsif ( $mailpref =~ /suspend/i ) { push( @suspended, $rcpt ) } - else { push( @send_now, $rcpt ) } - } - - # Reset the relevant mail field. - $RT::Logger->debug( "Removing deferred recipients from $mailfield: line"); - if (@send_now) { - $self->SetHeader( $mailfield, join( ', ', @send_now ) ); - } else { # No recipients! Remove the header. - $self->TemplateObj->MIMEObj->head->delete($mailfield); - } - - # Push the deferred addresses into the appropriate field in - # our attribute hash, with the appropriate mail header. - $RT::Logger->debug( - "Setting deferred recipients for attribute creation"); - $digest_hash->{'daily'}->{$_} = {'header' => $mailfield , _sent => 0} for (@daily_digest); - $digest_hash->{'weekly'}->{$_} ={'header' => $mailfield, _sent => 0} for (@weekly_digest); - $digest_hash->{'susp'}->{$_} = {'header' => $mailfield, _sent =>0 } for (@suspended); - } - - if ( scalar keys %$digest_hash ) { - - # Save the hash so that we can add it as an attribute to the - # outgoing email transaction. - $self->{'Deferred'} = $digest_hash; - } else { - $RT::Logger->debug( "No recipients found for deferred delivery on " - . "transaction #" - . $self->TransactionObj->id ); - } -} - - - -sub RecordDeferredRecipients { - my $self = shift; - return unless exists $self->{'Deferred'}; - - my $txn_id = $self->{'OutgoingMailTransaction'}; - return unless $txn_id; - - my $txn_obj = RT::Transaction->new( $self->CurrentUser ); - $txn_obj->Load( $txn_id ); - my( $ret, $msg ) = $txn_obj->AddAttribute( - Name => 'DeferredRecipients', - Content => $self->{'Deferred'} - ); - $RT::Logger->warning( "Unable to add deferred recipients to outgoing transaction: $msg" ) - unless $ret; - - return ($ret,$msg); -} - -=head2 SquelchMailTo - -Returns list of the addresses to squelch on this transaction. - -=cut - -sub SquelchMailTo { - my $self = shift; - return map $_->Content, $self->TransactionObj->SquelchMailTo; -} - -=head2 RemoveInappropriateRecipients - -Remove addresses that are RT addresses or that are on this transaction's blacklist - -=cut - -sub RemoveInappropriateRecipients { - my $self = shift; - - my @blacklist = (); - - # If there are no recipients, don't try to send the message. - # If the transaction has content and has the header RT-Squelch-Replies-To - - my $msgid = Encode::decode( "UTF-8", $self->TemplateObj->MIMEObj->head->get('Message-Id') ); - if ( my $attachment = $self->TransactionObj->Attachments->First ) { - - if ( $attachment->GetHeader('RT-DetectedAutoGenerated') ) { - - # What do we want to do with this? It's probably (?) a bounce - # caused by one of the watcher addresses being broken. - # Default ("true") is to redistribute, for historical reasons. - - if ( !RT->Config->Get('RedistributeAutoGeneratedMessages') ) { - - # Don't send to any watchers. - @{ $self->{$_} } = () for (@EMAIL_RECIPIENT_HEADERS); - $RT::Logger->info( $msgid - . " The incoming message was autogenerated. " - . "Not redistributing this message based on site configuration." - ); - } elsif ( RT->Config->Get('RedistributeAutoGeneratedMessages') eq - 'privileged' ) - { - - # Only send to "privileged" watchers. - foreach my $type (@EMAIL_RECIPIENT_HEADERS) { - foreach my $addr ( @{ $self->{$type} } ) { - my $user = RT::User->new(RT->SystemUser); - $user->LoadByEmail($addr); - push @blacklist, $addr unless $user->id && $user->Privileged; - } - } - $RT::Logger->info( $msgid - . " The incoming message was autogenerated. " - . "Not redistributing this message to unprivileged users based on site configuration." - ); - } - } - - if ( my $squelch = $attachment->GetHeader('RT-Squelch-Replies-To') ) { - push @blacklist, split( /,/, $squelch ); - } - } - - # Let's grab the SquelchMailTo attributes and push those entries into the @blacklisted - push @blacklist, map $_->Content, $self->TicketObj->SquelchMailTo, $self->TransactionObj->SquelchMailTo; - - # Cycle through the people we're sending to and pull out anyone on the - # system blacklist - - # Trim leading and trailing spaces. - @blacklist = map { RT::User->CanonicalizeEmailAddress( $_->address ) } - Email::Address->parse( join ', ', grep defined, @blacklist ); - - foreach my $type (@EMAIL_RECIPIENT_HEADERS) { - my @addrs; - foreach my $addr ( @{ $self->{$type} } ) { - - # Weed out any RT addresses. We really don't want to talk to ourselves! - # If we get a reply back, that means it's not an RT address - if ( !RT::EmailParser->CullRTAddresses($addr) ) { - $RT::Logger->info( $msgid . "$addr appears to point to this RT instance. Skipping" ); - next; - } - if ( grep $addr eq $_, @blacklist ) { - $RT::Logger->info( $msgid . "$addr was blacklisted for outbound mail on this transaction. Skipping"); - next; - } - push @addrs, $addr; - } - foreach my $addr ( @{ $self->{'NoSquelch'}{$type} || [] } ) { - # never send email to itself - if ( !RT::EmailParser->CullRTAddresses($addr) ) { - $RT::Logger->info( $msgid . "$addr appears to point to this RT instance. Skipping" ); - next; - } - push @addrs, $addr; - } - @{ $self->{$type} } = @addrs; - } -} - -=head2 SetReturnAddress is_comment => BOOLEAN - -Calculate and set From and Reply-To headers based on the is_comment flag. - -=cut - -sub SetReturnAddress { - - my $self = shift; - my %args = ( - is_comment => 0, - friendly_name => undef, - @_ - ); - - # From and Reply-To - # $args{is_comment} should be set if the comment address is to be used. - my $replyto; - - if ( $args{'is_comment'} ) { - $replyto = $self->TicketObj->QueueObj->CommentAddress - || RT->Config->Get('CommentAddress'); - } else { - $replyto = $self->TicketObj->QueueObj->CorrespondAddress - || RT->Config->Get('CorrespondAddress'); - } - - unless ( $self->TemplateObj->MIMEObj->head->get('From') ) { - $self->SetFrom( %args, From => $replyto ); - } - - unless ( $self->TemplateObj->MIMEObj->head->get('Reply-To') ) { - $self->SetHeader( 'Reply-To', "$replyto" ); - } - -} - -=head2 SetFrom ( From => emailaddress ) - -Set the From: address for outgoing email - -=cut - -sub SetFrom { - my $self = shift; - my %args = @_; - - my $from = $args{From}; - - if ( RT->Config->Get('UseFriendlyFromLine') ) { - my $friendly_name = $self->GetFriendlyName(%args); - $from = - sprintf( - RT->Config->Get('FriendlyFromLineFormat'), - $self->MIMEEncodeString( - $friendly_name, RT->Config->Get('EmailOutputEncoding') - ), - $args{From} - ); - } - - $self->SetHeader( 'From', $from ); - - #also set Sender:, otherwise MTAs add a nonsensical value like rt@machine, - #and then Outlook prepends "rt@machine on behalf of" to the From: header - $self->SetHeader( 'Sender', $from ); -} - -=head2 GetFriendlyName - -Calculate the proper Friendly Name based on the creator of the transaction - -=cut - -sub GetFriendlyName { - my $self = shift; - my %args = ( - is_comment => 0, - friendly_name => '', - @_ - ); - my $friendly_name = $args{friendly_name}; - - unless ( $friendly_name ) { - $friendly_name = $self->TransactionObj->CreatorObj->FriendlyName; - if ( $friendly_name =~ /^"(.*)"$/ ) { # a quoted string - $friendly_name = $1; - } - } - - $friendly_name =~ s/"/\\"/g; - return $friendly_name; - -} - -=head2 SetHeader FIELD, VALUE - -Set the FIELD of the current MIME object into VALUE, which should be in -characters, not bytes. Returns the new header, in bytes. - -=cut - -sub SetHeader { - my $self = shift; - my $field = shift; - my $val = shift; - - chomp $val; - chomp $field; - my $head = $self->TemplateObj->MIMEObj->head; - $head->fold_length( $field, 10000 ); - $head->replace( $field, Encode::encode( "UTF-8", $val ) ); - return $head->get($field); -} - -=head2 SetSubject - -This routine sets the subject. it does not add the rt tag. That gets done elsewhere -If subject is already defined via template, it uses that. otherwise, it tries to get -the transaction's subject. - -=cut - -sub SetSubject { - my $self = shift; - my $subject; - - if ( $self->TemplateObj->MIMEObj->head->get('Subject') ) { - return (); - } - - # don't use Transaction->Attachments because it caches - # and anything which later calls ->Attachments will be hurt - # by our RowsPerPage() call. caching is hard. - my $message = RT::Attachments->new( $self->CurrentUser ); - $message->Limit( FIELD => 'TransactionId', VALUE => $self->TransactionObj->id); - $message->OrderBy( FIELD => 'id', ORDER => 'ASC' ); - $message->RowsPerPage(1); - - if ( $self->{'Subject'} ) { - $subject = $self->{'Subject'}; - } elsif ( my $first = $message->First ) { - my $tmp = $first->GetHeader('Subject'); - $subject = defined $tmp ? $tmp : $self->TicketObj->Subject; - } else { - $subject = $self->TicketObj->Subject; - } - $subject = '' unless defined $subject; - chomp $subject; - - $subject =~ s/(\r\n|\n|\s)/ /g; - - $self->SetHeader( 'Subject', $subject ); - -} - -=head2 SetSubjectToken - -This routine fixes the RT tag in the subject. It's unlikely that you want to overwrite this. - -=cut - -sub SetSubjectToken { - my $self = shift; - - my $head = $self->TemplateObj->MIMEObj->head; - $self->SetHeader( - Subject => - RT::Interface::Email::AddSubjectTag( - Encode::decode( "UTF-8", $head->get('Subject') ), - $self->TicketObj, - ), - ); -} - -=head2 SetReferencesHeaders - -Set References and In-Reply-To headers for this message. - -=cut - -sub SetReferencesHeaders { - my $self = shift; - - my $top = $self->TransactionObj->Message->First; - unless ( $top ) { - $self->SetHeader( References => $self->PseudoReference ); - return (undef); - } - - my @in_reply_to = split( /\s+/m, $top->GetHeader('In-Reply-To') || '' ); - my @references = split( /\s+/m, $top->GetHeader('References') || '' ); - my @msgid = split( /\s+/m, $top->GetHeader('Message-ID') || '' ); - - # There are two main cases -- this transaction was created with - # the RT Web UI, and hence we want to *not* append its Message-ID - # to the References and In-Reply-To. OR it came from an outside - # source, and we should treat it as per the RFC - my $org = RT->Config->Get('Organization'); - if ( "@msgid" =~ /<(rt-.*?-\d+-\d+)\.(\d+)-0-0\@\Q$org\E>/ ) { - - # Make all references which are internal be to version which we - # have sent out - - for ( @references, @in_reply_to ) { - s/<(rt-.*?-\d+-\d+)\.(\d+-0-0)\@\Q$org\E>$/ - "<$1." . $self->TicketObj->id . - "-" . $self->ScripObj->id . - "-" . $self->ScripActionObj->{_Message_ID} . - "@" . $org . ">"/eg - } - - # In reply to whatever the internal message was in reply to - $self->SetHeader( 'In-Reply-To', join( " ", (@in_reply_to) ) ); - - # Default the references to whatever we're in reply to - @references = @in_reply_to unless @references; - - # References are unchanged from internal - } else { - - # In reply to that message - $self->SetHeader( 'In-Reply-To', join( " ", (@msgid) ) ); - - # Default the references to whatever we're in reply to - @references = @in_reply_to unless @references; - - # Push that message onto the end of the references - push @references, @msgid; - } - - # Push pseudo-ref to the front - my $pseudo_ref = $self->PseudoReference; - @references = ( $pseudo_ref, grep { $_ ne $pseudo_ref } @references ); - - # If there are more than 10 references headers, remove all but the - # first four and the last six (Gotta keep this from growing - # forever) - splice( @references, 4, -6 ) if ( $#references >= 10 ); - - # Add on the references - $self->SetHeader( 'References', join( " ", @references ) ); - $self->TemplateObj->MIMEObj->head->fold_length( 'References', 80 ); - -} - -=head2 PseudoReference - -Returns a fake Message-ID: header for the ticket to allow a base level of threading - -=cut - -sub PseudoReference { - - my $self = shift; - my $pseudo_ref - = '<RT-Ticket-' - . $self->TicketObj->id . '@' - . RT->Config->Get('Organization') . '>'; - return $pseudo_ref; -} - -=head2 SetHeaderAsEncoding($field_name, $charset_encoding) - -This routine converts the field into specified charset encoding, then -applies the MIME-Header transfer encoding. - -=cut - -sub SetHeaderAsEncoding { - my $self = shift; - my ( $field, $enc ) = ( shift, shift ); - - my $head = $self->TemplateObj->MIMEObj->head; - - if ( lc($field) eq 'from' and RT->Config->Get('SMTPFrom') ) { - $head->replace( $field, Encode::encode( "UTF-8", RT->Config->Get('SMTPFrom') ) ); - return; - } - - my $value = Encode::decode("UTF-8", $head->get( $field )); - $value = $self->MIMEEncodeString( $value, $enc ); # Returns bytes - $head->replace( $field, $value ); - -} - -=head2 MIMEEncodeString - -Takes a perl string and optional encoding pass it over -L<RT::Interface::Email/EncodeToMIME>. - -Basicly encode a string using B encoding according to RFC2047, returning -bytes. - -=cut - -sub MIMEEncodeString { - my $self = shift; - return RT::Interface::Email::EncodeToMIME( String => $_[0], Charset => $_[1] ); -} - -RT::Base->_ImportOverlays(); - -1; - diff --git a/rt/lib/RT/Interface/Email.pm.orig b/rt/lib/RT/Interface/Email.pm.orig deleted file mode 100755 index f860461e0..000000000 --- a/rt/lib/RT/Interface/Email.pm.orig +++ /dev/null @@ -1,1949 +0,0 @@ -# BEGIN BPS TAGGED BLOCK {{{ -# -# COPYRIGHT: -# -# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC -# <sales@bestpractical.com> -# -# (Except where explicitly superseded by other copyright notices) -# -# -# LICENSE: -# -# This work is made available to you under the terms of Version 2 of -# the GNU General Public License. A copy of that license should have -# been provided with this software, but in any event can be snarfed -# from www.gnu.org. -# -# This work is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA -# 02110-1301 or visit their web page on the internet at -# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html. -# -# -# CONTRIBUTION SUBMISSION POLICY: -# -# (The following paragraph is not intended to limit the rights granted -# to you to modify and distribute this software under the terms of -# the GNU General Public License and is only of importance to you if -# you choose to contribute your changes and enhancements to the -# community by submitting them to Best Practical Solutions, LLC.) -# -# By intentionally submitting any modifications, corrections or -# derivatives to this work, or any other work intended for use with -# Request Tracker, to Best Practical Solutions, LLC, you confirm that -# you are the copyright holder for those contributions and you grant -# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable, -# royalty-free, perpetual, license to use, copy, create derivative -# works based on those contributions, and sublicense and distribute -# those contributions and any derivatives thereof. -# -# END BPS TAGGED BLOCK }}} - -package RT::Interface::Email; - -use strict; -use warnings; - -use Email::Address; -use MIME::Entity; -use RT::EmailParser; -use File::Temp; -use UNIVERSAL::require; -use Mail::Mailer (); -use Text::ParseWords qw/shellwords/; - -BEGIN { - use base 'Exporter'; - use vars qw ( @EXPORT_OK); - - # set the version for version checking - our $VERSION = 2.0; - - # your exported package globals go here, - # as well as any optionally exported functions - @EXPORT_OK = qw( - &CreateUser - &GetMessageContent - &CheckForLoops - &CheckForSuspiciousSender - &CheckForAutoGenerated - &CheckForBounce - &MailError - &ParseCcAddressesFromHead - &ParseSenderAddressFromHead - &ParseErrorsToAddressFromHead - &ParseAddressFromHeader - &Gateway); - -} - -=head1 NAME - - RT::Interface::Email - helper functions for parsing email sent to RT - -=head1 SYNOPSIS - - use lib "!!RT_LIB_PATH!!"; - use lib "!!RT_ETC_PATH!!"; - - use RT::Interface::Email qw(Gateway CreateUser); - -=head1 DESCRIPTION - - - - -=head1 METHODS - -=head2 CheckForLoops HEAD - -Takes a HEAD object of L<MIME::Head> class and returns true if the -message's been sent by this RT instance. Uses "X-RT-Loop-Prevention" -field of the head for test. - -=cut - -sub CheckForLoops { - my $head = shift; - - # If this instance of RT sent it our, we don't want to take it in - my $RTLoop = Encode::decode( "UTF-8", $head->get("X-RT-Loop-Prevention") || "" ); - chomp ($RTLoop); # remove that newline - if ( $RTLoop eq RT->Config->Get('rtname') ) { - return 1; - } - - # TODO: We might not trap the case where RT instance A sends a mail - # to RT instance B which sends a mail to ... - return undef; -} - -=head2 CheckForSuspiciousSender HEAD - -Takes a HEAD object of L<MIME::Head> class and returns true if sender -is suspicious. Suspicious means mailer daemon. - -See also L</ParseSenderAddressFromHead>. - -=cut - -sub CheckForSuspiciousSender { - my $head = shift; - - #if it's from a postmaster or mailer daemon, it's likely a bounce. - - #TODO: better algorithms needed here - there is no standards for - #bounces, so it's very difficult to separate them from anything - #else. At the other hand, the Return-To address is only ment to be - #used as an error channel, we might want to put up a separate - #Return-To address which is treated differently. - - #TODO: search through the whole email and find the right Ticket ID. - - my ( $From, $junk ) = ParseSenderAddressFromHead($head); - - # If unparseable (non-ASCII), $From can come back undef - return undef if not defined $From; - - if ( ( $From =~ /^mailer-daemon\@/i ) - or ( $From =~ /^postmaster\@/i ) - or ( $From eq "" )) - { - return (1); - - } - - return undef; -} - -=head2 CheckForAutoGenerated HEAD - -Takes a HEAD object of L<MIME::Head> class and returns true if message -is autogenerated. Checks 'Precedence' and 'X-FC-Machinegenerated' -fields of the head in tests. - -=cut - -sub CheckForAutoGenerated { - my $head = shift; - - my $Precedence = $head->get("Precedence") || ""; - if ( $Precedence =~ /^(bulk|junk)/i ) { - return (1); - } - - # Per RFC3834, any Auto-Submitted header which is not "no" means - # it is auto-generated. - my $AutoSubmitted = $head->get("Auto-Submitted") || ""; - if ( length $AutoSubmitted and $AutoSubmitted ne "no" ) { - return (1); - } - - # First Class mailer uses this as a clue. - my $FCJunk = $head->get("X-FC-Machinegenerated") || ""; - if ( $FCJunk =~ /^true/i ) { - return (1); - } - - return (0); -} - - -sub CheckForBounce { - my $head = shift; - - my $ReturnPath = $head->get("Return-path") || ""; - return ( $ReturnPath =~ /<>/ ); -} - - -=head2 MailError PARAM HASH - -Sends an error message. Takes a param hash: - -=over 4 - -=item From - sender's address, by default is 'CorrespondAddress'; - -=item To - recipient, by default is 'OwnerEmail'; - -=item Bcc - optional Bcc recipients; - -=item Subject - subject of the message, default is 'There has been an error'; - -=item Explanation - main content of the error, default value is 'Unexplained error'; - -=item MIMEObj - optional MIME entity that's attached to the error mail, as well we -add 'In-Reply-To' field to the error that points to this message. - -=item Attach - optional text that attached to the error as 'message/rfc822' part. - -=item LogLevel - log level under which we should write the subject and -explanation message into the log, by default we log it as critical. - -=back - -=cut - -sub MailError { - my %args = ( - To => RT->Config->Get('OwnerEmail'), - Bcc => undef, - From => RT->Config->Get('CorrespondAddress'), - Subject => 'There has been an error', - Explanation => 'Unexplained error', - MIMEObj => undef, - Attach => undef, - LogLevel => 'crit', - @_ - ); - - $RT::Logger->log( - level => $args{'LogLevel'}, - message => "$args{Subject}: $args{'Explanation'}", - ) if $args{'LogLevel'}; - - # the colons are necessary to make ->build include non-standard headers - my %entity_args = ( - Type => "multipart/mixed", - From => Encode::encode( "UTF-8", $args{'From'} ), - Bcc => Encode::encode( "UTF-8", $args{'Bcc'} ), - To => Encode::encode( "UTF-8", $args{'To'} ), - Subject => EncodeToMIME( String => $args{'Subject'} ), - 'X-RT-Loop-Prevention:' => Encode::encode( "UTF-8", RT->Config->Get('rtname') ), - ); - - # only set precedence if the sysadmin wants us to - if (defined(RT->Config->Get('DefaultErrorMailPrecedence'))) { - $entity_args{'Precedence:'} = - Encode::encode( "UTF-8", RT->Config->Get('DefaultErrorMailPrecedence') ); - } - - my $entity = MIME::Entity->build(%entity_args); - SetInReplyTo( Message => $entity, InReplyTo => $args{'MIMEObj'} ); - - $entity->attach( - Type => "text/plain", - Charset => "UTF-8", - Data => Encode::encode( "UTF-8", $args{'Explanation'} . "\n" ), - ); - - if ( $args{'MIMEObj'} ) { - $args{'MIMEObj'}->sync_headers; - $entity->add_part( $args{'MIMEObj'} ); - } - - if ( $args{'Attach'} ) { - $entity->attach( Data => Encode::encode( "UTF-8", $args{'Attach'} ), Type => 'message/rfc822' ); - - } - - SendEmail( Entity => $entity, Bounce => 1 ); -} - - -=head2 SendEmail Entity => undef, [ Bounce => 0, Ticket => undef, Transaction => undef ] - -Sends an email (passed as a L<MIME::Entity> object C<ENTITY>) using -RT's outgoing mail configuration. If C<BOUNCE> is passed, and is a -true value, the message will be marked as an autogenerated error, if -possible. Sets Date field of the head to now if it's not set. - -If the C<X-RT-Squelch> header is set to any true value, the mail will -not be sent. One use is to let extensions easily cancel outgoing mail. - -Ticket and Transaction arguments are optional. If Transaction is -specified and Ticket is not then ticket of the transaction is -used, but only if the transaction belongs to a ticket. - -Returns 1 on success, 0 on error or -1 if message has no recipients -and hasn't been sent. - -=head3 Signing and Encrypting - -This function as well signs and/or encrypts the message according to -headers of a transaction's attachment or properties of a ticket's queue. -To get full access to the configuration Ticket and/or Transaction -arguments must be provided, but you can force behaviour using Sign -and/or Encrypt arguments. - -The following precedence of arguments are used to figure out if -the message should be encrypted and/or signed: - -* if Sign or Encrypt argument is defined then its value is used - -* else if Transaction's first attachment has X-RT-Sign or X-RT-Encrypt -header field then it's value is used - -* else properties of a queue of the Ticket are used. - -=cut - -sub WillSignEncrypt { - my %args = @_; - my $attachment = delete $args{Attachment}; - my $ticket = delete $args{Ticket}; - - if ( not RT->Config->Get('GnuPG')->{'Enable'} ) { - $args{Sign} = $args{Encrypt} = 0; - return wantarray ? %args : 0; - } - - for my $argument ( qw(Sign Encrypt) ) { - next if defined $args{ $argument }; - - if ( $attachment and defined $attachment->GetHeader("X-RT-$argument") ) { - $args{$argument} = $attachment->GetHeader("X-RT-$argument"); - } elsif ( $ticket and $argument eq "Encrypt" ) { - $args{Encrypt} = $ticket->QueueObj->Encrypt(); - } elsif ( $ticket and $argument eq "Sign" ) { - # Note that $queue->Sign is UI-only, and that all - # UI-generated messages explicitly set the X-RT-Crypt header - # to 0 or 1; thus this path is only taken for messages - # generated _not_ via the web UI. - $args{Sign} = $ticket->QueueObj->SignAuto(); - } - } - - return wantarray ? %args : ($args{Sign} || $args{Encrypt}); -} - -sub SendEmail { - my (%args) = ( - Entity => undef, - Bounce => 0, - Ticket => undef, - Transaction => undef, - @_, - ); - - my $TicketObj = $args{'Ticket'}; - my $TransactionObj = $args{'Transaction'}; - - foreach my $arg( qw(Entity Bounce) ) { - next unless defined $args{ lc $arg }; - - $RT::Logger->warning("'". lc($arg) ."' argument is deprecated, use '$arg' instead"); - $args{ $arg } = delete $args{ lc $arg }; - } - - unless ( $args{'Entity'} ) { - $RT::Logger->crit( "Could not send mail without 'Entity' object" ); - return 0; - } - - my $msgid = Encode::decode( "UTF-8", $args{'Entity'}->head->get('Message-ID') || '' ); - chomp $msgid; - - # If we don't have any recipients to send to, don't send a message; - unless ( $args{'Entity'}->head->get('To') - || $args{'Entity'}->head->get('Cc') - || $args{'Entity'}->head->get('Bcc') ) - { - $RT::Logger->info( $msgid . " No recipients found. Not sending." ); - return -1; - } - - if ($args{'Entity'}->head->get('X-RT-Squelch')) { - $RT::Logger->info( $msgid . " Squelch header found. Not sending." ); - return -1; - } - - if ( $TransactionObj && !$TicketObj - && $TransactionObj->ObjectType eq 'RT::Ticket' ) - { - $TicketObj = $TransactionObj->Object; - } - - if ( RT->Config->Get('GnuPG')->{'Enable'} ) { - %args = WillSignEncrypt( - %args, - Attachment => $TransactionObj ? $TransactionObj->Attachments->First : undef, - Ticket => $TicketObj, - ); - my $res = SignEncrypt( %args ); - return $res unless $res > 0; - } - - unless ( $args{'Entity'}->head->get('Date') ) { - require RT::Date; - my $date = RT::Date->new( RT->SystemUser ); - $date->SetToNow; - $args{'Entity'}->head->set( 'Date', Encode::encode( "UTF-8", $date->RFC2822( Timezone => 'server' ) ) ); - } - - my $mail_command = RT->Config->Get('MailCommand'); - - if ($mail_command eq 'testfile' and not $Mail::Mailer::testfile::config{outfile}) { - $Mail::Mailer::testfile::config{outfile} = File::Temp->new; - $RT::Logger->info("Storing outgoing emails in $Mail::Mailer::testfile::config{outfile}"); - } - - # if it is a sub routine, we just return it; - return $mail_command->($args{'Entity'}) if UNIVERSAL::isa( $mail_command, 'CODE' ); - - if ( $mail_command eq 'sendmailpipe' ) { - my $path = RT->Config->Get('SendmailPath'); - my @args = shellwords(RT->Config->Get('SendmailArguments')); - - # SetOutgoingMailFrom and bounces conflict, since they both want -f - if ( $args{'Bounce'} ) { - push @args, shellwords(RT->Config->Get('SendmailBounceArguments')); - } elsif ( my $MailFrom = RT->Config->Get('SetOutgoingMailFrom') ) { - my $OutgoingMailAddress = $MailFrom =~ /\@/ ? $MailFrom : undef; - my $Overrides = RT->Config->Get('OverrideOutgoingMailFrom') || {}; - - if ($TicketObj) { - my $QueueName = $TicketObj->QueueObj->Name; - my $QueueAddressOverride = $Overrides->{$QueueName}; - - if ($QueueAddressOverride) { - $OutgoingMailAddress = $QueueAddressOverride; - } else { - $OutgoingMailAddress ||= $TicketObj->QueueObj->CorrespondAddress - || RT->Config->Get('CorrespondAddress'); - } - } - elsif ($Overrides->{'Default'}) { - $OutgoingMailAddress = $Overrides->{'Default'}; - } - - push @args, "-f", $OutgoingMailAddress - if $OutgoingMailAddress; - } - - # VERP - if ( $TransactionObj and - my $prefix = RT->Config->Get('VERPPrefix') and - my $domain = RT->Config->Get('VERPDomain') ) - { - my $from = $TransactionObj->CreatorObj->EmailAddress; - $from =~ s/@/=/g; - $from =~ s/\s//g; - push @args, "-f", "$prefix$from\@$domain"; - } - - eval { - # don't ignore CHLD signal to get proper exit code - local $SIG{'CHLD'} = 'DEFAULT'; - - # if something wrong with $mail->print we will get PIPE signal, handle it - local $SIG{'PIPE'} = sub { die "program unexpectedly closed pipe" }; - - require IPC::Open2; - my ($mail, $stdout); - my $pid = IPC::Open2::open2( $stdout, $mail, $path, @args ) - or die "couldn't execute program: $!"; - - $args{'Entity'}->print($mail); - close $mail or die "close pipe failed: $!"; - - waitpid($pid, 0); - if ($?) { - # sendmail exit statuses mostly errors with data not software - # TODO: status parsing: core dump, exit on signal or EX_* - my $msg = "$msgid: `$path @args` exited with code ". ($?>>8); - $msg = ", interrupted by signal ". ($?&127) if $?&127; - $RT::Logger->error( $msg ); - die $msg; - } - }; - if ( $@ ) { - $RT::Logger->crit( "$msgid: Could not send mail with command `$path @args`: " . $@ ); - if ( $TicketObj ) { - _RecordSendEmailFailure( $TicketObj ); - } - return 0; - } - } - elsif ( $mail_command eq 'smtp' ) { - require Net::SMTP; - my $smtp = do { local $@; eval { Net::SMTP->new( - Host => RT->Config->Get('SMTPServer'), - Debug => RT->Config->Get('SMTPDebug'), - ) } }; - unless ( $smtp ) { - $RT::Logger->crit( "Could not connect to SMTP server."); - if ($TicketObj) { - _RecordSendEmailFailure( $TicketObj ); - } - return 0; - } - - # duplicate head as we want drop Bcc field - my $head = $args{'Entity'}->head->dup; - my @recipients = map $_->address, map - Email::Address->parse(Encode::decode("UTF-8", $head->get($_))), - qw(To Cc Bcc); - $head->delete('Bcc'); - - my $sender = RT->Config->Get('SMTPFrom') - || Encode::decode( "UTF-8", $args{'Entity'}->head->get('From') ); - chomp $sender; - - my $status = $smtp->mail( $sender ) - && $smtp->recipient( @recipients ); - - if ( $status ) { - $smtp->data; - my $fh = $smtp->tied_fh; - $head->print( $fh ); - print $fh "\n"; - $args{'Entity'}->print_body( $fh ); - $smtp->dataend; - } - $smtp->quit; - - unless ( $status ) { - $RT::Logger->crit( "$msgid: Could not send mail via SMTP." ); - if ( $TicketObj ) { - _RecordSendEmailFailure( $TicketObj ); - } - return 0; - } - } - else { - local ($ENV{'MAILADDRESS'}, $ENV{'PERL_MAILERS'}); - - my @mailer_args = ($mail_command); - if ( $mail_command eq 'sendmail' ) { - $ENV{'PERL_MAILERS'} = RT->Config->Get('SendmailPath'); - push @mailer_args, split(/\s+/, RT->Config->Get('SendmailArguments')); - } - else { - push @mailer_args, RT->Config->Get('MailParams'); - } - - unless ( $args{'Entity'}->send( @mailer_args ) ) { - $RT::Logger->crit( "$msgid: Could not send mail." ); - if ( $TicketObj ) { - _RecordSendEmailFailure( $TicketObj ); - } - return 0; - } - } - return 1; -} - -=head2 PrepareEmailUsingTemplate Template => '', Arguments => {} - -Loads a template. Parses it using arguments if it's not empty. -Returns a tuple (L<RT::Template> object, error message). - -Note that even if a template object is returned MIMEObj method -may return undef for empty templates. - -=cut - -sub PrepareEmailUsingTemplate { - my %args = ( - Template => '', - Arguments => {}, - @_ - ); - - my $template = RT::Template->new( RT->SystemUser ); - $template->LoadGlobalTemplate( $args{'Template'} ); - unless ( $template->id ) { - return (undef, "Couldn't load template '". $args{'Template'} ."'"); - } - return $template if $template->IsEmpty; - - my ($status, $msg) = $template->Parse( %{ $args{'Arguments'} } ); - return (undef, $msg) unless $status; - - return $template; -} - -=head2 SendEmailUsingTemplate Template => '', Arguments => {}, From => CorrespondAddress, To => '', Cc => '', Bcc => '' - -Sends email using a template, takes name of template, arguments for it and recipients. - -=cut - -sub SendEmailUsingTemplate { - my %args = ( - Template => '', - Arguments => {}, - To => undef, - Cc => undef, - Bcc => undef, - From => RT->Config->Get('CorrespondAddress'), - InReplyTo => undef, - ExtraHeaders => {}, - @_ - ); - - my ($template, $msg) = PrepareEmailUsingTemplate( %args ); - return (0, $msg) unless $template; - - my $mail = $template->MIMEObj; - unless ( $mail ) { - $RT::Logger->info("Message is not sent as template #". $template->id ." is empty"); - return -1; - } - - $mail->head->set( $_ => Encode::encode( "UTF-8", $args{ $_ } ) ) - foreach grep defined $args{$_}, qw(To Cc Bcc From); - - $mail->head->set( $_ => Encode::encode( "UTF-8", $args{ExtraHeaders}{$_} ) ) - foreach keys %{ $args{ExtraHeaders} }; - - SetInReplyTo( Message => $mail, InReplyTo => $args{'InReplyTo'} ); - - return SendEmail( Entity => $mail ); -} - -=head2 ForwardTransaction TRANSACTION, To => '', Cc => '', Bcc => '' - -Forwards transaction with all attachments as 'message/rfc822'. - -=cut - -sub ForwardTransaction { - my $txn = shift; - my %args = ( To => '', Cc => '', Bcc => '', @_ ); - - my $entity = $txn->ContentAsMIME; - - my ( $ret, $msg ) = SendForward( %args, Entity => $entity, Transaction => $txn ); - if ($ret) { - my $ticket = $txn->TicketObj; - my ( $ret, $msg ) = $ticket->_NewTransaction( - Type => 'Forward Transaction', - Field => $txn->id, - Data => join ', ', grep { length } $args{To}, $args{Cc}, $args{Bcc}, - ); - unless ($ret) { - $RT::Logger->error("Failed to create transaction: $msg"); - } - } - return ( $ret, $msg ); -} - -=head2 ForwardTicket TICKET, To => '', Cc => '', Bcc => '' - -Forwards a ticket's Create and Correspond Transactions and their Attachments as 'message/rfc822'. - -=cut - -sub ForwardTicket { - my $ticket = shift; - my %args = ( To => '', Cc => '', Bcc => '', @_ ); - - my $txns = $ticket->Transactions; - $txns->Limit( - FIELD => 'Type', - VALUE => $_, - ) for qw(Create Correspond); - - my $entity = MIME::Entity->build( - Type => 'multipart/mixed', - Description => 'forwarded ticket', - ); - $entity->add_part( $_ ) foreach - map $_->ContentAsMIME, - @{ $txns->ItemsArrayRef }; - - my ( $ret, $msg ) = SendForward( - %args, - Entity => $entity, - Ticket => $ticket, - Template => 'Forward Ticket', - ); - - if ($ret) { - my ( $ret, $msg ) = $ticket->_NewTransaction( - Type => 'Forward Ticket', - Field => $ticket->id, - Data => join ', ', grep { length } $args{To}, $args{Cc}, $args{Bcc}, - ); - unless ($ret) { - $RT::Logger->error("Failed to create transaction: $msg"); - } - } - - return ( $ret, $msg ); - -} - -=head2 SendForward Entity => undef, Ticket => undef, Transaction => undef, Template => undef, To => '', Cc => '', Bcc => '' - -Forwards an Entity representing Ticket or Transaction as 'message/rfc822'. Entity is wrapped into Template. - -=cut - -sub SendForward { - my (%args) = ( - Entity => undef, - Ticket => undef, - Transaction => undef, - Template => 'Forward', - To => '', Cc => '', Bcc => '', - @_ - ); - - my $txn = $args{'Transaction'}; - my $ticket = $args{'Ticket'}; - $ticket ||= $txn->Object if $txn; - - my $entity = $args{'Entity'}; - unless ( $entity ) { - require Carp; - $RT::Logger->error(Carp::longmess("No entity provided")); - return (0, $ticket->loc("Couldn't send email")); - } - - my ($template, $msg) = PrepareEmailUsingTemplate( - Template => $args{'Template'}, - Arguments => { - Ticket => $ticket, - Transaction => $txn, - }, - ); - - my $mail; - if ( $template ) { - $mail = $template->MIMEObj; - } else { - $RT::Logger->warning($msg); - } - unless ( $mail ) { - $RT::Logger->warning("Couldn't generate email using template '$args{Template}'"); - - my $description; - unless ( $args{'Transaction'} ) { - $description = 'This is forward of ticket #'. $ticket->id; - } else { - $description = 'This is forward of transaction #' - . $txn->id ." of a ticket #". $txn->ObjectId; - } - $mail = MIME::Entity->build( - Type => 'text/plain', - Charset => "UTF-8", - Data => Encode::encode( "UTF-8", $description ), - ); - } - - $mail->head->set( $_ => EncodeToMIME( String => $args{$_} ) ) - foreach grep defined $args{$_}, qw(To Cc Bcc); - - $mail->make_multipart unless $mail->is_multipart; - $mail->add_part( $entity ); - - my $from; - unless (defined $mail->head->get('Subject')) { - my $subject = ''; - $subject = $txn->Subject if $txn; - $subject ||= $ticket->Subject if $ticket; - - unless ( RT->Config->Get('ForwardFromUser') ) { - # XXX: what if want to forward txn of other object than ticket? - $subject = AddSubjectTag( $subject, $ticket ); - } - - $mail->head->set( Subject => EncodeToMIME( String => "Fwd: $subject" ) ); - } - - $mail->head->set( - From => EncodeToMIME( - String => GetForwardFrom( Transaction => $txn, Ticket => $ticket ) - ) - ); - - my $status = RT->Config->Get('ForwardFromUser') - # never sign if we forward from User - ? SendEmail( %args, Entity => $mail, Sign => 0 ) - : SendEmail( %args, Entity => $mail ); - return (0, $ticket->loc("Couldn't send email")) unless $status; - return (1, $ticket->loc("Sent email successfully")); -} - -=head2 GetForwardFrom Ticket => undef, Transaction => undef - -Resolve the From field to use in forward mail - -=cut - -sub GetForwardFrom { - my %args = ( Ticket => undef, Transaction => undef, @_ ); - my $txn = $args{Transaction}; - my $ticket = $args{Ticket} || $txn->Object; - - if ( RT->Config->Get('ForwardFromUser') ) { - return ( $txn || $ticket )->CurrentUser->EmailAddress; - } - else { - return $ticket->QueueObj->CorrespondAddress - || RT->Config->Get('CorrespondAddress'); - } -} - -=head2 SignEncrypt Entity => undef, Sign => 0, Encrypt => 0 - -Signs and encrypts message using L<RT::Crypt::GnuPG>, but as well -handle errors with users' keys. - -If a recipient has no key or has other problems with it, then the -unction sends a error to him using 'Error: public key' template. -Also, notifies RT's owner using template 'Error to RT owner: public key' -to inform that there are problems with users' keys. Then we filter -all bad recipients and retry. - -Returns 1 on success, 0 on error and -1 if all recipients are bad and -had been filtered out. - -=cut - -sub SignEncrypt { - my %args = ( - Entity => undef, - Sign => 0, - Encrypt => 0, - @_ - ); - return 1 unless $args{'Sign'} || $args{'Encrypt'}; - - my $msgid = Encode::decode( "UTF-8", $args{'Entity'}->head->get('Message-ID') || '' ); - chomp $msgid; - - $RT::Logger->debug("$msgid Signing message") if $args{'Sign'}; - $RT::Logger->debug("$msgid Encrypting message") if $args{'Encrypt'}; - - require RT::Crypt::GnuPG; - my %res = RT::Crypt::GnuPG::SignEncrypt( %args ); - return 1 unless $res{'exit_code'}; - - my @status = RT::Crypt::GnuPG::ParseStatus( $res{'status'} ); - - my @bad_recipients; - foreach my $line ( @status ) { - # if the passphrase fails, either you have a bad passphrase - # or gpg-agent has died. That should get caught in Create and - # Update, but at least throw an error here - if (($line->{'Operation'}||'') eq 'PassphraseCheck' - && $line->{'Status'} =~ /^(?:BAD|MISSING)$/ ) { - $RT::Logger->error( "$line->{'Status'} PASSPHRASE: $line->{'Message'}" ); - return 0; - } - next unless ($line->{'Operation'}||'') eq 'RecipientsCheck'; - next if $line->{'Status'} eq 'DONE'; - $RT::Logger->error( $line->{'Message'} ); - push @bad_recipients, $line; - } - return 0 unless @bad_recipients; - - $_->{'AddressObj'} = (Email::Address->parse( $_->{'Recipient'} ))[0] - foreach @bad_recipients; - - foreach my $recipient ( @bad_recipients ) { - my $status = SendEmailUsingTemplate( - To => $recipient->{'AddressObj'}->address, - Template => 'Error: public key', - Arguments => { - %$recipient, - TicketObj => $args{'Ticket'}, - TransactionObj => $args{'Transaction'}, - }, - ); - unless ( $status ) { - $RT::Logger->error("Couldn't send 'Error: public key'"); - } - } - - my $status = SendEmailUsingTemplate( - To => RT->Config->Get('OwnerEmail'), - Template => 'Error to RT owner: public key', - Arguments => { - BadRecipients => \@bad_recipients, - TicketObj => $args{'Ticket'}, - TransactionObj => $args{'Transaction'}, - }, - ); - unless ( $status ) { - $RT::Logger->error("Couldn't send 'Error to RT owner: public key'"); - } - - DeleteRecipientsFromHead( - $args{'Entity'}->head, - map $_->{'AddressObj'}->address, @bad_recipients - ); - - unless ( $args{'Entity'}->head->get('To') - || $args{'Entity'}->head->get('Cc') - || $args{'Entity'}->head->get('Bcc') ) - { - $RT::Logger->debug("$msgid No recipients that have public key, not sending"); - return -1; - } - - # redo without broken recipients - %res = RT::Crypt::GnuPG::SignEncrypt( %args ); - return 0 if $res{'exit_code'}; - - return 1; -} - -use MIME::Words (); - -=head2 EncodeToMIME - -Takes a hash with a String and a Charset. Returns the string encoded -according to RFC2047, using B (base64 based) encoding. - -String must be a perl string, octets are returned. - -If Charset is not provided then $EmailOutputEncoding config option -is used, or "latin-1" if that is not set. - -=cut - -sub EncodeToMIME { - my %args = ( - String => undef, - Charset => undef, - @_ - ); - my $value = $args{'String'}; - return $value unless $value; # 0 is perfect ascii - my $charset = $args{'Charset'} || RT->Config->Get('EmailOutputEncoding'); - my $encoding = 'B'; - - # using RFC2047 notation, sec 2. - # encoded-word = "=?" charset "?" encoding "?" encoded-text "?=" - - # An 'encoded-word' may not be more than 75 characters long - # - # MIME encoding increases 4/3*(number of bytes), and always in multiples - # of 4. Thus we have to find the best available value of bytes available - # for each chunk. - # - # First we get the integer max which max*4/3 would fit on space. - # Then we find the greater multiple of 3 lower or equal than $max. - my $max = int( - ( ( 75 - length( '=?' . $charset . '?' . $encoding . '?' . '?=' ) ) - * 3 - ) / 4 - ); - $max = int( $max / 3 ) * 3; - - chomp $value; - - if ( $max <= 0 ) { - - # gives an error... - $RT::Logger->crit("Can't encode! Charset or encoding too big."); - return ($value); - } - - return ($value) if $value =~ /^(?:[\t\x20-\x7e]|\x0D*\x0A[ \t])+$/s; - - $value =~ s/\s+$//; - - my ( $tmp, @chunks ) = ( '', () ); - while ( length $value ) { - my $char = substr( $value, 0, 1, '' ); - my $octets = Encode::encode( $charset, $char ); - if ( length($tmp) + length($octets) > $max ) { - push @chunks, $tmp; - $tmp = ''; - } - $tmp .= $octets; - } - push @chunks, $tmp if length $tmp; - - # encode an join chuncks - $value = join "\n ", - map MIME::Words::encode_mimeword( $_, $encoding, $charset ), - @chunks; - return ($value); -} - -sub CreateUser { - my ( $Username, $Address, $Name, $ErrorsTo, $entity ) = @_; - - my $NewUser = RT::User->new( RT->SystemUser ); - - my ( $Val, $Message ) = $NewUser->Create( - Name => ( $Username || $Address ), - EmailAddress => $Address, - RealName => $Name, - Password => undef, - Privileged => 0, - Comments => 'Autocreated on ticket submission', - ); - - unless ($Val) { - - # Deal with the race condition of two account creations at once - if ($Username) { - $NewUser->LoadByName($Username); - } - - unless ( $NewUser->Id ) { - $NewUser->LoadByEmail($Address); - } - - unless ( $NewUser->Id ) { - MailError( - To => $ErrorsTo, - Subject => "User could not be created", - Explanation => - "User creation failed in mailgateway: $Message", - MIMEObj => $entity, - LogLevel => 'crit', - ); - } - } - - #Load the new user object - my $CurrentUser = RT::CurrentUser->new; - $CurrentUser->LoadByEmail( $Address ); - - unless ( $CurrentUser->id ) { - $RT::Logger->warning( - "Couldn't load user '$Address'." . "giving up" ); - MailError( - To => $ErrorsTo, - Subject => "User could not be loaded", - Explanation => - "User '$Address' could not be loaded in the mail gateway", - MIMEObj => $entity, - LogLevel => 'crit' - ); - } - - return $CurrentUser; -} - - - -=head2 ParseCcAddressesFromHead HASH - -Takes a hash containing QueueObj, Head and CurrentUser objects. -Returns a list of all email addresses in the To and Cc -headers b<except> the current Queue's email addresses, the CurrentUser's -email address and anything that the configuration sub RT::IsRTAddress matches. - -=cut - -sub ParseCcAddressesFromHead { - my %args = ( - Head => undef, - QueueObj => undef, - CurrentUser => undef, - @_ - ); - - my $current_address = lc $args{'CurrentUser'}->EmailAddress; - my $user = $args{'CurrentUser'}->UserObj; - - return - grep { $_ ne $current_address - && !RT::EmailParser->IsRTAddress( $_ ) - && !IgnoreCcAddress( $_ ) - } - map lc $user->CanonicalizeEmailAddress( $_->address ), - map RT::EmailParser->CleanupAddresses( Email::Address->parse( - Encode::decode( "UTF-8", $args{'Head'}->get( $_ ) ) ) ), - qw(To Cc); -} - -=head2 IgnoreCcAddress ADDRESS - -Returns true if ADDRESS matches the $IgnoreCcRegexp config variable. - -=cut - -sub IgnoreCcAddress { - my $address = shift; - if ( my $address_re = RT->Config->Get('IgnoreCcRegexp') ) { - return 1 if $address =~ /$address_re/i; - } - return undef; -} - -=head2 ParseSenderAddressFromHead HEAD - -Takes a MIME::Header object. Returns (user@host, friendly name, errors) -where the first two values are the From (evaluated in order of -Reply-To:, From:, Sender). - -A list of error messages may be returned even when a Sender value is -found, since it could be a parse error for another (checked earlier) -sender field. In this case, the errors aren't fatal, but may be useful -to investigate the parse failure. - -=cut - -sub ParseSenderAddressFromHead { - my $head = shift; - my @sender_headers = ('Reply-To', 'From', 'Sender'); - my @errors; # Accumulate any errors - - #Figure out who's sending this message. - foreach my $header ( @sender_headers ) { - my $addr_line = Encode::decode( "UTF-8", $head->get($header) ) || next; - my ($addr, $name) = ParseAddressFromHeader( $addr_line ); - # only return if the address is not empty - return ($addr, $name, @errors) if $addr; - - chomp $addr_line; - push @errors, "$header: $addr_line"; - } - - return (undef, undef, @errors); -} - -=head2 ParseErrorsToAddressFromHead HEAD - -Takes a MIME::Header object. Return a single value : user@host -of the From (evaluated in order of Return-path:,Errors-To:,Reply-To:, -From:, Sender) - -=cut - -sub ParseErrorsToAddressFromHead { - my $head = shift; - - #Figure out who's sending this message. - - foreach my $header ( 'Errors-To', 'Reply-To', 'From', 'Sender' ) { - - # If there's a header of that name - my $headerobj = Encode::decode( "UTF-8", $head->get($header) ); - if ($headerobj) { - my ( $addr, $name ) = ParseAddressFromHeader($headerobj); - - # If it's got actual useful content... - return ($addr) if ($addr); - } - } -} - - - -=head2 ParseAddressFromHeader ADDRESS - -Takes an address from C<$head->get('Line')> and returns a tuple: user@host, friendly name - -=cut - -sub ParseAddressFromHeader { - my $Addr = shift; - - # Some broken mailers send: ""Vincent, Jesse"" <jesse@fsck.com>. Hate - $Addr =~ s/\"\"(.*?)\"\"/\"$1\"/g; - my @Addresses = RT::EmailParser->ParseEmailAddress($Addr); - - my ($AddrObj) = grep ref $_, @Addresses; - unless ( $AddrObj ) { - return ( undef, undef ); - } - - return ( $AddrObj->address, $AddrObj->phrase ); -} - -=head2 DeleteRecipientsFromHead HEAD RECIPIENTS - -Gets a head object and list of addresses. -Deletes addresses from To, Cc or Bcc fields. - -=cut - -sub DeleteRecipientsFromHead { - my $head = shift; - my %skip = map { lc $_ => 1 } @_; - - foreach my $field ( qw(To Cc Bcc) ) { - $head->set( $field => Encode::encode( "UTF-8", - join ', ', map $_->format, grep !$skip{ lc $_->address }, - Email::Address->parse( Encode::decode( "UTF-8", $head->get( $field ) ) ) ) - ); - } -} - -sub GenMessageId { - my %args = ( - Ticket => undef, - Scrip => undef, - ScripAction => undef, - @_ - ); - my $org = RT->Config->Get('Organization'); - my $ticket_id = ( ref $args{'Ticket'}? $args{'Ticket'}->id : $args{'Ticket'} ) || 0; - my $scrip_id = ( ref $args{'Scrip'}? $args{'Scrip'}->id : $args{'Scrip'} ) || 0; - my $sent = ( ref $args{'ScripAction'}? $args{'ScripAction'}->{'_Message_ID'} : 0 ) || 0; - - return "<rt-". $RT::VERSION ."-". $$ ."-". CORE::time() ."-". int(rand(2000)) .'.' - . $ticket_id ."-". $scrip_id ."-". $sent ."@". $org .">" ; -} - -sub SetInReplyTo { - my %args = ( - Message => undef, - InReplyTo => undef, - Ticket => undef, - @_ - ); - return unless $args{'Message'} && $args{'InReplyTo'}; - - my $get_header = sub { - my @res; - if ( $args{'InReplyTo'}->isa('MIME::Entity') ) { - @res = map {Encode::decode("UTF-8", $_)} $args{'InReplyTo'}->head->get( shift ); - } else { - @res = $args{'InReplyTo'}->GetHeader( shift ) || ''; - } - return grep length, map { split /\s+/m, $_ } grep defined, @res; - }; - - my @id = $get_header->('Message-ID'); - #XXX: custom header should begin with X- otherwise is violation of the standard - my @rtid = $get_header->('RT-Message-ID'); - my @references = $get_header->('References'); - unless ( @references ) { - @references = $get_header->('In-Reply-To'); - } - push @references, @id, @rtid; - if ( $args{'Ticket'} ) { - my $pseudo_ref = '<RT-Ticket-'. $args{'Ticket'}->id .'@'. RT->Config->Get('Organization') .'>'; - push @references, $pseudo_ref unless grep $_ eq $pseudo_ref, @references; - } - @references = splice @references, 4, -6 - if @references > 10; - - my $mail = $args{'Message'}; - $mail->head->set( 'In-Reply-To' => Encode::encode( "UTF-8", join ' ', @rtid? (@rtid) : (@id)) ) if @id || @rtid; - $mail->head->set( 'References' => Encode::encode( "UTF-8", join ' ', @references) ); -} - -sub ExtractTicketId { - my $entity = shift; - - my $subject = Encode::decode( "UTF-8", $entity->head->get('Subject') || '' ); - chomp $subject; - return ParseTicketId( $subject ); -} - -sub ParseTicketId { - my $Subject = shift; - - my $rtname = RT->Config->Get('rtname'); - my $test_name = RT->Config->Get('EmailSubjectTagRegex') || qr/\Q$rtname\E/i; - - my $id; - if ( $Subject =~ s/\[$test_name\s+\#(\d+)\s*\]//i ) { - $id = $1; - } else { - foreach my $tag ( RT->System->SubjectTag ) { - next unless $Subject =~ s/\[\Q$tag\E\s+\#(\d+)\s*\]//i; - $id = $1; - last; - } - } - return undef unless $id; - - $RT::Logger->debug("Found a ticket ID. It's $id"); - return $id; -} - -sub AddSubjectTag { - my $subject = shift; - my $ticket = shift; - unless ( ref $ticket ) { - my $tmp = RT::Ticket->new( RT->SystemUser ); - $tmp->Load( $ticket ); - $ticket = $tmp; - } - my $id = $ticket->id; - my $queue_tag = $ticket->QueueObj->SubjectTag; - - my $tag_re = RT->Config->Get('EmailSubjectTagRegex'); - unless ( $tag_re ) { - my $tag = $queue_tag || RT->Config->Get('rtname'); - $tag_re = qr/\Q$tag\E/; - } elsif ( $queue_tag ) { - $tag_re = qr/$tag_re|\Q$queue_tag\E/; - } - return $subject if $subject =~ /\[$tag_re\s+#$id\]/; - - $subject =~ s/(\r\n|\n|\s)/ /g; - chomp $subject; - return "[". ($queue_tag || RT->Config->Get('rtname')) ." #$id] $subject"; -} - - -=head2 Gateway ARGSREF - - -Takes parameters: - - action - queue - message - - -This performs all the "guts" of the mail rt-mailgate program, and is -designed to be called from the web interface with a message, user -object, and so on. - -Can also take an optional 'ticket' parameter; this ticket id overrides -any ticket id found in the subject. - -Returns: - - An array of: - - (status code, message, optional ticket object) - - status code is a numeric value. - - for temporary failures, the status code should be -75 - - for permanent failures which are handled by RT, the status code - should be 0 - - for succces, the status code should be 1 - - - -=cut - -sub _LoadPlugins { - my @mail_plugins = @_; - - my @res; - foreach my $plugin (@mail_plugins) { - if ( ref($plugin) eq "CODE" ) { - push @res, $plugin; - } elsif ( !ref $plugin ) { - my $Class = $plugin; - $Class = "RT::Interface::Email::" . $Class - unless $Class =~ /^RT::/; - $Class->require or - do { $RT::Logger->error("Couldn't load $Class: $@"); next }; - - no strict 'refs'; - unless ( defined *{ $Class . "::GetCurrentUser" }{CODE} ) { - $RT::Logger->crit( "No GetCurrentUser code found in $Class module"); - next; - } - push @res, $Class; - } else { - $RT::Logger->crit( "$plugin - is not class name or code reference"); - } - } - return @res; -} - -sub Gateway { - my $argsref = shift; - my %args = ( - action => 'correspond', - queue => '1', - ticket => undef, - message => undef, - %$argsref - ); - - my $SystemTicket; - my $Right; - - # Validate the action - my ( $status, @actions ) = IsCorrectAction( $args{'action'} ); - unless ($status) { - return ( - -75, - "Invalid 'action' parameter " - . $actions[0] - . " for queue " - . $args{'queue'}, - undef - ); - } - - my $parser = RT::EmailParser->new(); - $parser->SmartParseMIMEEntityFromScalar( - Message => $args{'message'}, - Decode => 0, - Exact => 1, - ); - - my $Message = $parser->Entity(); - unless ($Message) { - MailError( - Subject => "RT Bounce: Unparseable message", - Explanation => "RT couldn't process the message below", - Attach => $args{'message'} - ); - - return ( 0, - "Failed to parse this message. Something is likely badly wrong with the message" - ); - } - - my @mail_plugins = grep $_, RT->Config->Get('MailPlugins'); - push @mail_plugins, "Auth::MailFrom" unless @mail_plugins; - @mail_plugins = _LoadPlugins( @mail_plugins ); - - my %skip_plugin; - foreach my $class( grep !ref, @mail_plugins ) { - # check if we should apply filter before decoding - my $check_cb = do { - no strict 'refs'; - *{ $class . "::ApplyBeforeDecode" }{CODE}; - }; - next unless defined $check_cb; - next unless $check_cb->( - Message => $Message, - RawMessageRef => \$args{'message'}, - ); - - $skip_plugin{ $class }++; - - my $Code = do { - no strict 'refs'; - *{ $class . "::GetCurrentUser" }{CODE}; - }; - my ($status, $msg) = $Code->( - Message => $Message, - RawMessageRef => \$args{'message'}, - ); - next if $status > 0; - - if ( $status == -2 ) { - return (1, $msg, undef); - } elsif ( $status == -1 ) { - return (0, $msg, undef); - } - } - @mail_plugins = grep !$skip_plugin{"$_"}, @mail_plugins; - $parser->_DecodeBodies; - $parser->RescueOutlook; - $parser->_PostProcessNewEntity; - - my $head = $Message->head; - my $ErrorsTo = ParseErrorsToAddressFromHead( $head ); - my $Sender = (ParseSenderAddressFromHead( $head ))[0]; - my $From = Encode::decode( "UTF-8", $head->get("From") ); - chomp $From if defined $From; - - my $MessageId = Encode::decode( "UTF-8", $head->get('Message-ID') ) - || "<no-message-id-". time . rand(2000) .'@'. RT->Config->Get('Organization') .'>'; - - #Pull apart the subject line - my $Subject = Encode::decode( "UTF-8", $head->get('Subject') || ''); - chomp $Subject; - - # Lets check for mail loops of various sorts. - my ($should_store_machine_generated_message, $IsALoop, $result); - ( $should_store_machine_generated_message, $ErrorsTo, $result, $IsALoop ) = - _HandleMachineGeneratedMail( - Message => $Message, - ErrorsTo => $ErrorsTo, - Subject => $Subject, - MessageId => $MessageId - ); - - # Do not pass loop messages to MailPlugins, to make sure the loop - # is broken, unless $RT::StoreLoops is set. - if ($IsALoop && !$should_store_machine_generated_message) { - return ( 0, $result, undef ); - } - # }}} - - $args{'ticket'} ||= ExtractTicketId( $Message ); - - # ExtractTicketId may have been overridden, and edited the Subject - my $NewSubject = Encode::decode( "UTF-8", $Message->head->get('Subject') ); - chomp $NewSubject; - - $SystemTicket = RT::Ticket->new( RT->SystemUser ); - $SystemTicket->Load( $args{'ticket'} ) if ( $args{'ticket'} ) ; - if ( $SystemTicket->id ) { - $Right = 'ReplyToTicket'; - } else { - $Right = 'CreateTicket'; - } - - #Set up a queue object - my $SystemQueueObj = RT::Queue->new( RT->SystemUser ); - $SystemQueueObj->Load( $args{'queue'} ); - - # We can safely have no queue of we have a known-good ticket - unless ( $SystemTicket->id || $SystemQueueObj->id ) { - return ( -75, "RT couldn't find the queue: " . $args{'queue'}, undef ); - } - - my ($AuthStat, $CurrentUser, $error) = GetAuthenticationLevel( - MailPlugins => \@mail_plugins, - Actions => \@actions, - Message => $Message, - RawMessageRef => \$args{message}, - SystemTicket => $SystemTicket, - SystemQueue => $SystemQueueObj, - ); - - # If authentication fails and no new user was created, get out. - if ( !$CurrentUser || !$CurrentUser->id || $AuthStat == -1 ) { - - # If the plugins refused to create one, they lose. - unless ( $AuthStat == -1 ) { - _NoAuthorizedUserFound( - Right => $Right, - Message => $Message, - Requestor => $ErrorsTo, - Queue => $args{'queue'} - ); - - } - return ( 0, "Could not load a valid user", undef ); - } - - # If we got a user, but they don't have the right to say things - if ( $AuthStat == 0 ) { - MailError( - To => $ErrorsTo, - Subject => "Permission Denied", - Explanation => - "You do not have permission to communicate with RT", - MIMEObj => $Message - ); - return ( - 0, - ($CurrentUser->EmailAddress || $CurrentUser->Name) - . " ($Sender) tried to submit a message to " - . $args{'Queue'} - . " without permission.", - undef - ); - } - - - unless ($should_store_machine_generated_message) { - return ( 0, $result, undef ); - } - - # if plugin's updated SystemTicket then update arguments - $args{'ticket'} = $SystemTicket->Id if $SystemTicket && $SystemTicket->Id; - - my $Ticket = RT::Ticket->new($CurrentUser); - - if ( !$args{'ticket'} && grep /^(comment|correspond)$/, @actions ) - { - - my @Cc; - my @Requestors = ( $CurrentUser->id ); - - if (RT->Config->Get('ParseNewMessageForTicketCcs')) { - @Cc = ParseCcAddressesFromHead( - Head => $head, - CurrentUser => $CurrentUser, - QueueObj => $SystemQueueObj - ); - } - - $head->replace('X-RT-Interface' => 'Email'); - - my ( $id, $Transaction, $ErrStr ) = $Ticket->Create( - Queue => $SystemQueueObj->Id, - Subject => $NewSubject, - Requestor => \@Requestors, - Cc => \@Cc, - MIMEObj => $Message - ); - if ( $id == 0 ) { - MailError( - To => $ErrorsTo, - Subject => "Ticket creation failed: $Subject", - Explanation => $ErrStr, - MIMEObj => $Message - ); - return ( 0, "Ticket creation From: $From failed: $ErrStr", $Ticket ); - } - - # strip comments&corresponds from the actions we don't need - # to record them if we've created the ticket just now - @actions = grep !/^(comment|correspond)$/, @actions; - $args{'ticket'} = $id; - - } elsif ( $args{'ticket'} ) { - - $Ticket->Load( $args{'ticket'} ); - unless ( $Ticket->Id ) { - my $error = "Could not find a ticket with id " . $args{'ticket'}; - MailError( - To => $ErrorsTo, - Subject => "Message not recorded: $Subject", - Explanation => $error, - MIMEObj => $Message - ); - - return ( 0, $error ); - } - $args{'ticket'} = $Ticket->id; - } else { - return ( 1, "Success", $Ticket ); - } - - # }}} - - my $unsafe_actions = RT->Config->Get('UnsafeEmailCommands'); - foreach my $action (@actions) { - - # If the action is comment, add a comment. - if ( $action =~ /^(?:comment|correspond)$/i ) { - my $method = ucfirst lc $action; - my ( $status, $msg ) = $Ticket->$method( MIMEObj => $Message ); - unless ($status) { - - #Warn the sender that we couldn't actually submit the comment. - MailError( - To => $ErrorsTo, - Subject => "Message not recorded ($method): $Subject", - Explanation => $msg, - MIMEObj => $Message - ); - return ( 0, "Message From: $From not recorded: $msg", $Ticket ); - } - } elsif ($unsafe_actions) { - my ( $status, $msg ) = _RunUnsafeAction( - Action => $action, - ErrorsTo => $ErrorsTo, - Message => $Message, - Ticket => $Ticket, - CurrentUser => $CurrentUser, - ); - return ($status, $msg, $Ticket) unless $status == 1; - } - } - return ( 1, "Success", $Ticket ); -} - -=head2 GetAuthenticationLevel - - # Authentication Level - # -1 - Get out. this user has been explicitly declined - # 0 - User may not do anything (Not used at the moment) - # 1 - Normal user - # 2 - User is allowed to specify status updates etc. a la enhanced-mailgate - -=cut - -sub GetAuthenticationLevel { - my %args = ( - MailPlugins => [], - Actions => [], - Message => undef, - RawMessageRef => undef, - SystemTicket => undef, - SystemQueue => undef, - @_, - ); - - my ( $CurrentUser, $AuthStat, $error ); - - # Initalize AuthStat so comparisons work correctly - $AuthStat = -9999999; - - # if plugin returns AuthStat -2 we skip action - # NOTE: this is experimental API and it would be changed - my %skip_action = (); - - # Since this needs loading, no matter what - foreach (@{ $args{MailPlugins} }) { - my ($Code, $NewAuthStat); - if ( ref($_) eq "CODE" ) { - $Code = $_; - } else { - no strict 'refs'; - $Code = *{ $_ . "::GetCurrentUser" }{CODE}; - } - - foreach my $action (@{ $args{Actions} }) { - ( $CurrentUser, $NewAuthStat ) = $Code->( - Message => $args{Message}, - RawMessageRef => $args{RawMessageRef}, - CurrentUser => $CurrentUser, - AuthLevel => $AuthStat, - Action => $action, - Ticket => $args{SystemTicket}, - Queue => $args{SystemQueue}, - ); - -# You get the highest level of authentication you were assigned, unless you get the magic -1 -# If a module returns a "-1" then we discard the ticket, so. - $AuthStat = $NewAuthStat - if ( $NewAuthStat > $AuthStat or $NewAuthStat == -1 or $NewAuthStat == -2 ); - - last if $AuthStat == -1; - $skip_action{$action}++ if $AuthStat == -2; - } - - # strip actions we should skip - @{$args{Actions}} = grep !$skip_action{$_}, @{$args{Actions}} - if $AuthStat == -2; - last unless @{$args{Actions}}; - - last if $AuthStat == -1; - } - - return $AuthStat if !wantarray; - - return ($AuthStat, $CurrentUser, $error); -} - -sub _RunUnsafeAction { - my %args = ( - Action => undef, - ErrorsTo => undef, - Message => undef, - Ticket => undef, - CurrentUser => undef, - @_ - ); - - my $From = Encode::decode( "UTF-8", $args{Message}->head->get("From") ); - - if ( $args{'Action'} =~ /^take$/i ) { - my ( $status, $msg ) = $args{'Ticket'}->SetOwner( $args{'CurrentUser'}->id ); - unless ($status) { - MailError( - To => $args{'ErrorsTo'}, - Subject => "Ticket not taken", - Explanation => $msg, - MIMEObj => $args{'Message'} - ); - return ( 0, "Ticket not taken, by email From: $From" ); - } - } elsif ( $args{'Action'} =~ /^resolve$/i ) { - my $new_status = $args{'Ticket'}->FirstInactiveStatus; - if ($new_status) { - my ( $status, $msg ) = $args{'Ticket'}->SetStatus($new_status); - unless ($status) { - - #Warn the sender that we couldn't actually submit the comment. - MailError( - To => $args{'ErrorsTo'}, - Subject => "Ticket not resolved", - Explanation => $msg, - MIMEObj => $args{'Message'} - ); - return ( 0, "Ticket not resolved, by email From: $From" ); - } - } - } else { - return ( 0, "Not supported unsafe action $args{'Action'}, by email From: $From", $args{'Ticket'} ); - } - return ( 1, "Success" ); -} - -=head2 _NoAuthorizedUserFound - -Emails the RT Owner and the requestor when the auth plugins return "No auth user found" - -=cut - -sub _NoAuthorizedUserFound { - my %args = ( - Right => undef, - Message => undef, - Requestor => undef, - Queue => undef, - @_ - ); - - # Notify the RT Admin of the failure. - MailError( - To => RT->Config->Get('OwnerEmail'), - Subject => "Could not load a valid user", - Explanation => <<EOT, -RT could not load a valid user, and RT's configuration does not allow -for the creation of a new user for this email (@{[$args{Requestor}]}). - -You might need to grant 'Everyone' the right '@{[$args{Right}]}' for the -queue @{[$args{'Queue'}]}. - -EOT - MIMEObj => $args{'Message'}, - LogLevel => 'error' - ); - - # Also notify the requestor that his request has been dropped. - if ($args{'Requestor'} ne RT->Config->Get('OwnerEmail')) { - MailError( - To => $args{'Requestor'}, - Subject => "Could not load a valid user", - Explanation => <<EOT, -RT could not load a valid user, and RT's configuration does not allow -for the creation of a new user for your email. - -EOT - MIMEObj => $args{'Message'}, - LogLevel => 'error' - ); - } -} - -=head2 _HandleMachineGeneratedMail - -Takes named params: - Message - ErrorsTo - Subject - -Checks the message to see if it's a bounce, if it looks like a loop, if it's autogenerated, etc. -Returns a triple of ("Should we continue (boolean)", "New value for $ErrorsTo", "Status message", -"This message appears to be a loop (boolean)" ); - -=cut - -sub _HandleMachineGeneratedMail { - my %args = ( Message => undef, ErrorsTo => undef, Subject => undef, MessageId => undef, @_ ); - my $head = $args{'Message'}->head; - my $ErrorsTo = $args{'ErrorsTo'}; - - my $IsBounce = CheckForBounce($head); - - my $IsAutoGenerated = CheckForAutoGenerated($head); - - my $IsSuspiciousSender = CheckForSuspiciousSender($head); - - my $IsALoop = CheckForLoops($head); - - my $SquelchReplies = 0; - - my $owner_mail = RT->Config->Get('OwnerEmail'); - - #If the message is autogenerated, we need to know, so we can not - # send mail to the sender - if ( $IsBounce || $IsSuspiciousSender || $IsAutoGenerated || $IsALoop ) { - $SquelchReplies = 1; - $ErrorsTo = $owner_mail; - } - - # Warn someone if it's a loop, before we drop it on the ground - if ($IsALoop) { - $RT::Logger->crit("RT Received mail (".$args{MessageId}.") from itself."); - - #Should we mail it to RTOwner? - if ( RT->Config->Get('LoopsToRTOwner') ) { - MailError( - To => $owner_mail, - Subject => "RT Bounce: ".$args{'Subject'}, - Explanation => "RT thinks this message may be a bounce", - MIMEObj => $args{Message} - ); - } - - #Do we actually want to store it? - return ( 0, $ErrorsTo, "Message Bounced", $IsALoop ) - unless RT->Config->Get('StoreLoops'); - } - - # Squelch replies if necessary - # Don't let the user stuff the RT-Squelch-Replies-To header. - if ( $head->get('RT-Squelch-Replies-To') ) { - $head->replace( - 'RT-Relocated-Squelch-Replies-To', - $head->get('RT-Squelch-Replies-To') - ); - $head->delete('RT-Squelch-Replies-To'); - } - - if ($SquelchReplies) { - - # Squelch replies to the sender, and also leave a clue to - # allow us to squelch ALL outbound messages. This way we - # can punt the logic of "what to do when we get a bounce" - # to the scrip. We might want to notify nobody. Or just - # the RT Owner. Or maybe all Privileged watchers. - my ( $Sender, $junk ) = ParseSenderAddressFromHead($head); - $head->replace( 'RT-Squelch-Replies-To', Encode::encode("UTF-8", $Sender ) ); - $head->replace( 'RT-DetectedAutoGenerated', 'true' ); - } - return ( 1, $ErrorsTo, "Handled machine detection", $IsALoop ); -} - -=head2 IsCorrectAction - -Returns a list of valid actions we've found for this message - -=cut - -sub IsCorrectAction { - my $action = shift; - my @actions = grep $_, split /-/, $action; - return ( 0, '(no value)' ) unless @actions; - foreach ( @actions ) { - return ( 0, $_ ) unless /^(?:comment|correspond|take|resolve)$/; - } - return ( 1, @actions ); -} - -sub _RecordSendEmailFailure { - my $ticket = shift; - if ($ticket) { - $ticket->_RecordNote( - NoteType => 'SystemError', - Content => "Sending the previous mail has failed. Please contact your admin, they can find more details in the logs.", - ); - return 1; - } - else { - $RT::Logger->error( "Can't record send email failure as ticket is missing" ); - return; - } -} - -RT::Base->_ImportOverlays(); - -1; |