1 package BSE::ComposeMail;
6 use Digest::MD5 qw(md5_hex);
9 our $VERSION = "1.010";
13 BSE::ComposeMail - compose mail for BSE
18 my $mailer = BSE::ComposeMail->new(cfg => $cfg);
21 # to can either be an email (assumed to be a sysadmin email)
22 # or a member object (used to determine text or text and html)
23 # but this text vs mixed is unimplemented for now
24 $mailer->send(to => $member_object,
26 template => $template,
29 # html_template => $html_template # def $template."_html"
31 ) or die $mailer->errstr;
34 $mailer->start( ... parameters as above ...);
37 my $cidurl = $mailer->attach(file => $filename,
38 # disposition => 'attachment',
39 # display => $filename,
40 # type => 'application/octet-stream'
41 ) or die $mailer->errstr;
42 # display required unless disposition set to other than "attachment"
43 my $cidurl2 = $mailer->attach(fh => $fh,
44 display => $display_filename,
46 my $cidurl3 = $mailer->attach(data => $data,
47 display => $display_filename,
51 $mailer->encrypt_body(signing_id => $id,
52 passphrase => $passphrase);
55 $mailer->encrypt_body(signing_id => '');
57 # encrypt signed based on the [shop].crypt_signing_id
58 $mailer->encrypt_body();
61 $mailer->done() or die $mailer->errstr;
66 my ($class, %opts) = @_;
68 $opts{cfg} ||= BSE::Cfg->single;
74 my ($self, %opts) = @_;
81 my ($self, %opts) = @_;
83 for my $arg (qw(acts template to subject)) {
84 unless ($opts{$arg}) {
85 confess "Argument $arg missing\n";
87 $self->{$arg} = $opts{$arg};
90 $self->{from} = $opts{from}
91 || $self->{cfg}->entry('shop', 'from', $Constants::SHOP_FROM);
93 $self->{html_template} = $opts{html_template}
94 || "$self->{template}_html";
96 $self->{extra_mail_args} = {};
97 for my $arg (qw(to_name from_name cc bcc)) {
99 and $self->{extra_mail_args}{$arg} = $opts{$arg};
102 $self->{content} = '';
104 unless (defined $self->{allow_html}) {
105 if (ref $self->{to}) {
106 # being sent to a site user, use their setting
107 $opts{log_object} ||= $self->{to};
108 $self->{allow_html} = $self->{to}->allow_html_email;
109 $self->{to} = $self->{to}{email};
112 $self->{allow_html} =
113 $self->{cfg}->entry('mail', 'html_system_email', 0);
117 delete $self->{baseid};
118 $self->{attachments} = [];
119 $self->{encrypt} = 0;
124 component => "composemail::send",
126 for my $key (keys %opts) {
127 if ($key =~ /^log_(\w+)$/) {
128 $self->{log}{$1} = $opts{$key};
133 Scalar::Util::weaken($weak);
136 bse => BSE::Variables->variables(),
139 $self->{subject} = $_[0];
141 ( $opts{vars} ? %{$opts{vars}} : () ),
150 unless ($self->{baseid}) {
151 # something sort of random
152 $self->{baseid} = md5_hex(join(",", time, map rand, 0..15));
156 return $self->{baseid} . "." . ++$self->{idseq};
160 my ($self, %opts) = @_;
162 my $disp = $opts{disposition} || 'attachment';
163 my $display = $opts{display};
164 if ($disp eq 'attachment' && !$display) {
166 ($display = $opts{file}) =~ s!.*[/:\\]!!;
167 $display =~ tr/a-zA-Z_./_/cs;
170 $self->{errstr} = "You must supply display to attach() if you don't supply file";
176 # don't attach the same file twice if we can avoid it
177 for my $attachment (@{$self->{attachments}}) {
178 if ($attachment->{file} && $attachment->{file} eq $opts{file}) {
179 return $attachment->{url};
184 my $id = $self->_gen_id;
186 my $type = $opts{type} || 'application/octet-stream';
189 unless (-e $opts{file}) {
190 $self->{errstr} = "Attachment file $opts{file} doesn't exist";
194 push @{$self->{attachments}},
197 disposition => $disp,
205 push @{$self->{attachments}},
208 disposition => $disp,
215 elsif ($opts{data}) {
216 push @{$self->{attachments}},
219 disposition => $disp,
227 $self->{errstr} = "No file/fh/data supplied to attach()";
235 my ($self, %opts) = @_;
237 $self->{encrypt} = 1;
238 $self->{signing_id} = $opts{signing_id};
239 $self->{passphrase} = $opts{passphrase};
240 $self->{crypt_recipient} = $opts{recipient};
243 sub _build_internal {
244 my ($self, $content, $text_type, $headers) = @_;
247 if (@{$self->{attachments}}) {
249 my $boundary = $self->{baseid}."boundary";
250 push @$headers, "MIME-Version: 1.0";
251 push @$headers, qq!Content-Type: multipart/mixed; boundary="$boundary"!;
255 Content-Type: $text_type
256 Content-Disposition: inline
260 for my $attachment (@{$self->{attachments}}) {
262 if ($attachment->{file}) {
263 if (open DATA, "< $attachment->{file}") {
265 $data = do { local $/; <DATA> };
269 $self->{errstr} = "Could not open attachment $attachment->{file}: $!";
273 elsif ($attachment->{fh}) {
274 my $fh = $attachment->{fh};
276 $data = do { local $/; <$fh> };
278 elsif ($attachment->{data}) {
279 $data = $attachment->{data};
282 confess "Internal error: attachment with no file/fh/data";
285 my $is_text = $attachment->{type} =~ m!^text/!
286 && $data =~ tr/ -~\r\n\t//c;
290 # we might add 7bit here at some point
292 require MIME::QuotedPrint;
293 $encoded = MIME::QuotedPrint::encode_qp($data);
294 $encoding = 'quoted-printable';
297 require MIME::Base64;
298 $encoded = MIME::Base64::encode_base64($data);
299 $encoding = 'base64';
301 my $disp = $attachment->{disposition};
302 if ($disp eq 'attachment') {
303 $disp .= qq!; filename="$attachment->{display}"!;
307 Content-Type: $attachment->{type}
308 Content-Transfer-Encoding: $encoding
309 Content-Disposition: $disp
310 Content-Id: <$attachment->{id}>
315 $message =~ /\n\z/ or $message .= "\n";
316 $message .= "--$boundary--\n";
320 "Content-Type: $text_type",
328 sub _build_mime_lite {
329 my ($self, $text_content, $html_content, $headers) = @_;
333 $text_content .= "\n" unless $text_content =~ /\n$/;
334 $html_content .= "\n" unless $html_content =~ /\n$/;
336 my $charset = $self->{cfg}->charset;
338 my $msg = MIME::Lite->new
340 From => $self->{from},
341 "Errors-To:" => $self->{from},
342 Subject => $self->{subject},
343 Type => 'multipart/alternative',
345 my $text_part = $msg->attach
347 Type => "text/plain; charset=$charset",
348 Data => [ $text_content ],
349 $text_content =~ /.{79}/ || $text_content =~ /[^ -~\x0d\x0a]/
350 ? ( Encoding => 'quoted-printable' ) : (),
352 my $html_part = $msg->attach(Type => 'multipart/related');
355 Type => BSE::Template->html_type($self->{cfg}),
356 Data => $html_content,
357 Encoding => 'quoted-printable',
360 for my $attachment (@{$self->{attachments}}) {
362 if ($attachment->{file}) {
363 if (open DATA, "< $attachment->{file}") {
365 $data = do { local $/; <DATA> };
369 $self->{errstr} = "Could not open attachment $attachment->{file}: $!";
373 elsif ($attachment->{fh}) {
374 my $fh = $attachment->{fh};
376 $data = do { local $/; <$fh> };
378 elsif ($attachment->{data}) {
379 $data = $attachment->{data};
382 confess "Internal error: attachment with no file/fh/data";
386 Type => $attachment->{type},
388 Id => "<$attachment->{id}>" # <> required by RFC
390 if ($attachment->{disposition} eq 'attachment' || $attachment->{display}) {
391 $opts{Filename} = $attachment->{display};
393 $html_part->attach(%opts);
396 my $header_str = $msg->header_as_string;
397 for my $header (split /\n/, $header_str) {
398 my ($key, $value) = $header =~ /^([^:]+): *(.*)/
400 # the mailer adds these in later
401 unless ($key =~ /^(from|to|subject)$/i) {
402 push @$headers, $header;
406 return $msg->body_as_string;
412 my $section = $self->{header_section} || "mail headers for $self->{template}";
414 my %extras = $self->{cfg}->entriesCS($section);
415 for my $key (keys %extras) {
416 (my $head_key = $key) =~ s/_/-/g;
417 push @headers, "$head_key: $extras{$key}";
424 my ($self, $headers, $message) = @_;
426 my $max = $self->{cfg}->entry("audit log", "mail_max_dump", 50000);
427 my $msg = "$headers\n\n$message";
428 if (length($msg) > $max) {
429 substr($msg, $max-3) = "...";
441 resource => [ tag_resource => $self ],
442 set_subject => [ tag_set_subject => $self ],
447 my $content = BSE::Template->
448 get_page($self->{template}, $self->{cfg}, \%acts, undef, undef, $self->{vars});
450 $content = BSE::Template->encode_content($content, $self->{cfg});
452 if (!$self->{allow_html} || $self->{encrypt} ||
453 !BSE::Template->find_source($self->{html_template}, $self->{cfg})) {
454 my $text_type = 'text/plain';
455 if ($self->{encrypt}) {
456 $content = $self->_encrypt($content, \$text_type);
460 $message = $self->_build_internal($content, $text_type, \@headers);
463 my $html_content = BSE::Template->
464 get_page($self->{html_template}, $self->{cfg}, \%acts, undef, undef, $self->{vars});
466 my $inline_css = $self->{cfg}->entry("mail", "inline_css", "style");
467 if (($inline_css eq "style" && $html_content =~ /<style/i)
468 || $inline_css eq "force") {
469 my $report_failure = $self->{cfg}->entry("mail", "inline_css_report", 1);
470 my %inline_opts = map { $_ => 1 } split /,/,
471 $self->{cfg}->entry("mail", "inline_css_flags", "");
473 require CSS::Inliner;
474 my $inliner = CSS::Inliner->new(\%inline_opts);
476 $inliner->read({html => $html_content});
477 $html_content = $inliner->inlinify;
480 if (!$good && $report_failure) {
482 require BSE::TB::AuditLog;
483 my %log = %{$self->{log}};
494 BSE::TB::AuditLog->log
497 msg => "Error inlining CSS",
498 component => "composemail:done:inlinecss",
505 $html_content = BSE::Template->encode_content($html_content, $self->{cfg});
507 $message = $self->_build_mime_lite($content, $html_content, \@headers);
509 push @headers, $self->extra_headers;
510 my $mailer = BSE::Mail->new(cfg => $self->{cfg});
511 my $headers = join "", map "$_\n", @headers;
513 if ($mailer->send(to => $self->{to},
514 from => $self->{from},
515 subject => $self->{subject},
518 %{$self->{extra_mail_args}})) {
519 if ($self->{cfg}->entry("audit log", "mail", 0)) {
520 my %log_opts = %{$self->{log}};
521 $log_opts{msg} ||= "Mail sent to $self->{to}";
522 $log_opts{component} ||= "composemail:done:send";
523 $log_opts{level} ||= "info";
524 $log_opts{actor} ||= "S";
525 $log_opts{dump} =$self->_log_dump($headers, $message);
526 require BSE::TB::AuditLog;
527 BSE::TB::AuditLog->log(%log_opts);
532 my %log_opts = %{$self->{log}};
533 $log_opts{msg} = "Error sending email: " . $mailer->errstr;
534 $log_opts{component} ||= "composemail:done:send";
535 $log_opts{level} ||= "error";
536 $log_opts{actor} ||= "S";
537 $log_opts{dump} = $self->_log_dump($headers, $message);
538 require BSE::TB::AuditLog;
539 BSE::TB::AuditLog->log(%log_opts);
541 $self->{errstr} = "Send error: ", $mailer->errstr;
542 print STDERR "Error sending mail ", $mailer->errstr, "\n";
548 my ($self, $content, $rtype) = @_;
550 my $cfg = $self->{cfg};
552 my $crypt_class = $cfg->entry('shop', 'crypt_module',
553 $Constants::SHOP_CRYPTO);
554 my $signing_id = defined($self->{signing_id}) ? $self->{signing_id}
555 : $cfg->entry('shop', 'crypt_signing_id',
556 $Constants::SHOP_SIGNING_ID);
557 my $passphrase = defined($self->{passphrase}) ? $self->{passphrase}
558 : $cfg->entry('shop', 'crypt_passphrase', $Constants::SHOP_PASSPHRASE);
559 my $gpg = $cfg->entry('shop', 'crypt_gpg', $Constants::SHOP_GPG);
560 my $pgp = $cfg->entry('shop', 'crypt_pgp', $Constants::SHOP_PGP);
561 my $pgpe = $cfg->entry('shop', 'crypt_pgpe', $Constants::SHOP_PGPE);
563 (my $class_file = $crypt_class.".pm") =~ s!::!/!g;
565 my $encryptor = $crypt_class->new;
568 passphrase => $passphrase,
570 debug => $cfg->entry('debug', 'mail_encryption', 0),
571 sign => !!$signing_id,
572 secretkeyid => $signing_id,
578 my $recip = $self->{crypt_recipient} || $self->{to};
580 my $result = $encryptor->encrypt($recip, $content, %opts);
582 my $dump = $encryptor->can("dump") ? $encryptor->dump : "See error log";
583 require BSE::TB::AuditLog;
584 BSE::TB::AuditLog->log
587 msg => "Error encrypting content: " . $encryptor->error,
589 component => "composemail:done:encrypt",
592 $self->{errstr} = "Error encrypting: " . $encryptor->error;
596 if ($cfg->entry('shop', 'crypt_content_type', 0)) {
597 $$rtype = 'application/pgp; format=text; x-action=encrypt';
608 my ($self, $args) = @_;
610 defined $args and $args =~ /^\w+$/
611 or return "** invalid resource id $args **";
613 if ($self->{resource}{$args}) {
614 return $self->{resource}{$args};
617 my $res_entry = $self->{cfg}->entry('mail resources', $args)
618 or return "** No resource $args found **";
619 my ($filename, $type, $inline) = split /,/, $res_entry;
622 if ($filename =~ /\.(gif|png|jpg)$/i) {
623 $type = lc $1 eq 'jpg' ? 'image/jpeg' : 'image/' . lc $1;
626 $type = 'application/octet-stream';
629 if (!defined $inline) {
630 $inline = $type =~ m!^image/!;
633 my $abs_filename = BSE::Template->find_source($filename, $self->{cfg})
634 or return "** file $filename for resource $args not found **";
636 (my $display = $filename) =~ s/.*[\/\\]//;
638 my $url = $self->attach(file => $abs_filename,
642 or return "** could not attach $args: $self->{errstr} **";
644 $self->{resource}{$args} = $url;
649 sub tag_set_subject {
650 my ($self, $args, $acts, $tag, $templater) = @_;
652 my @args = DevHelp::Tags->get_parms($args, $acts, $templater);
654 $self->{subject} = "@args";
660 my ($class, %opts) = @_;
662 my $cfg = BSE::Cfg->single;
663 my $mailer = $class->new(cfg => $cfg);
666 or confess "No mail id provided";
668 my $section = "email $id";
670 for my $key (qw/subject template html_template allow_html from from_name/) {
671 my $value = $cfg->entry($section, $key);
672 defined $value and $opts{$key} = $value;
674 unless (defined $opts{acts}) {
675 require BSE::Util::Tags;
676 BSE::Util::Tags->import(qw/tag_hash_plain/);
679 BSE::Util::Tags->static(undef, $cfg),
681 if ($opts{extraacts}) {
682 %acts = ( %acts, %{$opts{extraacts}} );
684 $opts{acts} = \%acts;
688 or print STDERR "Error sending mail $id: ", $mailer->errstr, "\n";