1 package BSE::ComposeMail;
6 use Digest::MD5 qw(md5_hex);
9 our $VERSION = "1.009";
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 $msg = MIME::Lite->new
338 From => $self->{from},
339 "Errors-To:" => $self->{from},
340 Subject => $self->{subject},
341 Type => 'multipart/alternative',
343 my $text_part = $msg->attach
345 Type => 'text/plain',
346 Data => [ $text_content ],
347 $text_content =~ /.{79}/ || $text_content =~ /[^ -~\x0d\x0a]/
348 ? ( Encoding => 'quoted-printable' ) : (),
350 my $html_part = $msg->attach(Type => 'multipart/related');
354 Data => $html_content,
355 Encoding => 'quoted-printable',
358 for my $attachment (@{$self->{attachments}}) {
360 if ($attachment->{file}) {
361 if (open DATA, "< $attachment->{file}") {
363 $data = do { local $/; <DATA> };
367 $self->{errstr} = "Could not open attachment $attachment->{file}: $!";
371 elsif ($attachment->{fh}) {
372 my $fh = $attachment->{fh};
374 $data = do { local $/; <$fh> };
376 elsif ($attachment->{data}) {
377 $data = $attachment->{data};
380 confess "Internal error: attachment with no file/fh/data";
384 Type => $attachment->{type},
386 Id => "<$attachment->{id}>" # <> required by RFC
388 if ($attachment->{disposition} eq 'attachment' || $attachment->{display}) {
389 $opts{Filename} = $attachment->{display};
391 $html_part->attach(%opts);
394 my $header_str = $msg->header_as_string;
395 for my $header (split /\n/, $header_str) {
396 my ($key, $value) = $header =~ /^([^:]+): *(.*)/
398 # the mailer adds these in later
399 unless ($key =~ /^(from|to|subject)$/i) {
400 push @$headers, $header;
404 return $msg->body_as_string;
410 my $section = $self->{header_section} || "mail headers for $self->{template}";
412 my %extras = $self->{cfg}->entriesCS($section);
413 for my $key (keys %extras) {
414 (my $head_key = $key) =~ s/_/-/g;
415 push @headers, "$head_key: $extras{$key}";
422 my ($self, $headers, $message) = @_;
424 my $max = $self->{cfg}->entry("audit log", "mail_max_dump", 50000);
425 my $msg = "$headers\n\n$message";
426 if (length($msg) > $max) {
427 substr($msg, $max-3) = "...";
439 resource => [ tag_resource => $self ],
440 set_subject => [ tag_set_subject => $self ],
445 my $content = BSE::Template->
446 get_page($self->{template}, $self->{cfg}, \%acts, undef, undef, $self->{vars});
447 if (!$self->{allow_html} || $self->{encrypt} ||
448 !BSE::Template->find_source($self->{html_template}, $self->{cfg})) {
449 my $text_type = 'text/plain';
450 if ($self->{encrypt}) {
451 $content = $self->_encrypt($content, \$text_type);
455 $message = $self->_build_internal($content, $text_type, \@headers);
458 my $html_content = BSE::Template->
459 get_page($self->{html_template}, $self->{cfg}, \%acts, undef, undef, $self->{vars});
461 my $inline_css = $self->{cfg}->entry("mail", "inline_css", "style");
462 if (($inline_css eq "style" && $html_content =~ /<style/i)
463 || $inline_css eq "force") {
464 my $report_failure = $self->{cfg}->entry("mail", "inline_css_report", 1);
465 my %inline_opts = map { $_ => 1 } split /,/,
466 $self->{cfg}->entry("mail", "inline_css_flags", "");
468 require CSS::Inliner;
469 my $inliner = CSS::Inliner->new(\%inline_opts);
471 $inliner->read({html => $html_content});
472 $html_content = $inliner->inlinify;
475 if (!$good && $report_failure) {
477 require BSE::TB::AuditLog;
478 my %log = %{$self->{log}};
489 BSE::TB::AuditLog->log
492 msg => "Error inlining CSS",
493 component => "composemail:done:inlinecss",
500 $message = $self->_build_mime_lite($content, $html_content, \@headers);
502 push @headers, $self->extra_headers;
503 my $mailer = BSE::Mail->new(cfg => $self->{cfg});
504 my $headers = join "", map "$_\n", @headers;
506 if ($mailer->send(to => $self->{to},
507 from => $self->{from},
508 subject => $self->{subject},
511 %{$self->{extra_mail_args}})) {
512 if ($self->{cfg}->entry("audit log", "mail", 0)) {
513 my %log_opts = %{$self->{log}};
514 $log_opts{msg} ||= "Mail sent to $self->{to}";
515 $log_opts{component} ||= "composemail:done:send";
516 $log_opts{level} ||= "info";
517 $log_opts{actor} ||= "S";
518 $log_opts{dump} =$self->_log_dump($headers, $message);
519 require BSE::TB::AuditLog;
520 BSE::TB::AuditLog->log(%log_opts);
525 my %log_opts = %{$self->{log}};
526 $log_opts{msg} = "Error sending email: " . $mailer->errstr;
527 $log_opts{component} ||= "composemail:done:send";
528 $log_opts{level} ||= "error";
529 $log_opts{actor} ||= "S";
530 $log_opts{dump} = $self->_log_dump($headers, $message);
531 require BSE::TB::AuditLog;
532 BSE::TB::AuditLog->log(%log_opts);
534 $self->{errstr} = "Send error: ", $mailer->errstr;
535 print STDERR "Error sending mail ", $mailer->errstr, "\n";
541 my ($self, $content, $rtype) = @_;
543 my $cfg = $self->{cfg};
545 my $crypt_class = $cfg->entry('shop', 'crypt_module',
546 $Constants::SHOP_CRYPTO);
547 my $signing_id = defined($self->{signing_id}) ? $self->{signing_id}
548 : $cfg->entry('shop', 'crypt_signing_id',
549 $Constants::SHOP_SIGNING_ID);
550 my $passphrase = defined($self->{passphrase}) ? $self->{passphrase}
551 : $cfg->entry('shop', 'crypt_passphrase', $Constants::SHOP_PASSPHRASE);
552 my $gpg = $cfg->entry('shop', 'crypt_gpg', $Constants::SHOP_GPG);
553 my $pgp = $cfg->entry('shop', 'crypt_pgp', $Constants::SHOP_PGP);
554 my $pgpe = $cfg->entry('shop', 'crypt_pgpe', $Constants::SHOP_PGPE);
556 (my $class_file = $crypt_class.".pm") =~ s!::!/!g;
558 my $encryptor = $crypt_class->new;
561 passphrase => $passphrase,
563 debug => $cfg->entry('debug', 'mail_encryption', 0),
564 sign => !!$signing_id,
565 secretkeyid => $signing_id,
571 my $recip = $self->{crypt_recipient} || $self->{to};
573 my $result = $encryptor->encrypt($recip, $content, %opts);
575 my $dump = $encryptor->can("dump") ? $encryptor->dump : "See error log";
576 require BSE::TB::AuditLog;
577 BSE::TB::AuditLog->log
580 msg => "Error encrypting content: " . $encryptor->error,
582 component => "composemail:done:encrypt",
585 $self->{errstr} = "Error encrypting: " . $encryptor->error;
589 if ($cfg->entry('shop', 'crypt_content_type', 0)) {
590 $$rtype = 'application/pgp; format=text; x-action=encrypt';
601 my ($self, $args) = @_;
603 defined $args and $args =~ /^\w+$/
604 or return "** invalid resource id $args **";
606 if ($self->{resource}{$args}) {
607 return $self->{resource}{$args};
610 my $res_entry = $self->{cfg}->entry('mail resources', $args)
611 or return "** No resource $args found **";
612 my ($filename, $type, $inline) = split /,/, $res_entry;
615 if ($filename =~ /\.(gif|png|jpg)$/i) {
616 $type = lc $1 eq 'jpg' ? 'image/jpeg' : 'image/' . lc $1;
619 $type = 'application/octet-stream';
622 if (!defined $inline) {
623 $inline = $type =~ m!^image/!;
626 my $abs_filename = BSE::Template->find_source($filename, $self->{cfg})
627 or return "** file $filename for resource $args not found **";
629 (my $display = $filename) =~ s/.*[\/\\]//;
631 my $url = $self->attach(file => $abs_filename,
635 or return "** could not attach $args: $self->{errstr} **";
637 $self->{resource}{$args} = $url;
642 sub tag_set_subject {
643 my ($self, $args, $acts, $tag, $templater) = @_;
645 my @args = DevHelp::Tags->get_parms($args, $acts, $templater);
647 $self->{subject} = "@args";
653 my ($class, %opts) = @_;
655 my $cfg = BSE::Cfg->single;
656 my $mailer = $class->new(cfg => $cfg);
659 or confess "No mail id provided";
661 my $section = "email $id";
663 for my $key (qw/subject template html_template allow_html from from_name/) {
664 my $value = $cfg->entry($section, $key);
665 defined $value and $opts{$key} = $value;
667 unless (defined $opts{acts}) {
668 require BSE::Util::Tags;
669 BSE::Util::Tags->import(qw/tag_hash_plain/);
672 BSE::Util::Tags->static(undef, $cfg),
674 if ($opts{extraacts}) {
675 %acts = ( %acts, %{$opts{extraacts}} );
677 $opts{acts} = \%acts;
681 or print STDERR "Error sending mail $id: ", $mailer->errstr, "\n";