1=head1 Name 2 3OpenXPKI::Server::Notification::SMTP - Notification via SMTP 4 5=head1 Description 6 7This class implements a notifier that sends out notification as 8plain plain text message using Net::SMTP. The templates for the mails 9are read from the filesystem. 10 11=head1 Configuration 12 13 backend: 14 class: OpenXPKI::Server::Notification::SMTP 15 host: localhost 16 helo: my.own.fqdn 17 port: 25 18 starttls: 0 19 username: smtpuser 20 password: smtppass 21 debug: 0 22 23 default: 24 to: "[% cert_info.requestor_email %]" 25 from: no-reply@openxpki.org 26 reply: helpdesk@openxpki.org 27 cc: helpdesk@openxpki.org 28 prefix: PKI-Request [% meta_wf_id %] 29 30 template: 31 dir: /home/pkiadm/democa/mails/ 32 33 message: 34 csr_created: 35 default: 36 template: csr_created_user 37 subject: CSR for [% cert_subject %] 38 39 raop: 40 template: csr_created_raop # Suffix .txt is always added! 41 to: ra-officer@openxpki.org 42 reply: "[% cert_info.requestor_email %]" 43 subject: New CSR for [% cert_subject %] 44 45Calling the notifier with C<MESSAGE=csr_created> will send out two mails. 46One to the requestor and one to the ra-officer, both are CC'ed to helpdesk. 47 48=head2 Recipients 49 50=over 51 52=item to 53 54Must be a single address, can be a template toolkit string. 55 56=item cc 57 58Can be a single address or a list of address seperated by a single comma. 59The string is processed by template toolkit and afterwards splitted into 60a list at the comma, so you can use loop/join to create multiple recipients. 61 62If you directly pass an array, each item is processed using template toolkit 63but must return a single address. 64 65=back 66 67B<Note>: The settings To, Cc and Prefix are stored in the workflow on the first 68message and reused for each subsequent message on the same channel, so you can 69gurantee that each message in a thread is sent to the same people. All settings 70from default can be overriden in the local definition. Defaults can be blanked 71using an empty string. 72 73=head2 Sign outgoing mails using SMIME 74 75Outgoing emails can be signed using SMIME, add this section on the top 76 77level: 78 79 smime: 80 certificate_key_file: /usr/local/etc/openxpki/local/smime.key 81 certificate_file: /usr/local/etc/openxpki/local/smime.crt 82 certificate_key_password: secret 83 84Key and certificate must be PEM encoded, password can be omitted if the 85key is not encrypted. Key/cert can be provided by a PKCS12 file using 86certificate_p12_file (PKCS12 support requires Crypt::SMIME 0.17!). 87 88=cut 89 90package OpenXPKI::Server::Notification::SMTP; 91 92use strict; 93use warnings; 94use English; 95 96use Data::Dumper; 97 98use DateTime; 99use OpenXPKI::Server::Context qw( CTX ); 100use OpenXPKI::Exception; 101use OpenXPKI::Debug; 102use OpenXPKI::FileUtils; 103use OpenXPKI::Serialization::Simple; 104 105use Net::SMTP; 106use Net::Domain; 107 108use Moose; 109use Encode; 110 111extends 'OpenXPKI::Server::Notification::Base'; 112 113#use namespace::autoclean; # Comnflicts with Debugger 114 115# Attribute Setup 116 117has '_transport' => ( 118 is => 'rw', 119 # Net::SMTP Object 120 isa => 'Object|Undef', 121); 122 123has 'default_envelope' => ( 124 is => 'ro', 125 isa => 'HashRef', 126 builder => '_init_default_envelope', 127 lazy => 1, 128); 129 130has 'use_html' => ( 131 is => 'ro', 132 isa => 'Bool', 133 builder => '_init_use_html', 134 lazy => 1, 135); 136 137has 'is_smtp_open' => ( 138 is => 'rw', 139 isa => 'Bool', 140); 141 142has '_smime' => ( 143 is => 'rw', 144 isa => 'Object|Undef', 145 builder => '_init_smime', 146 lazy => 1, 147); 148 149sub transport { 150 151 ##! 1: 'fetch transport' 152 153 my $self = shift; 154 155 # We call reset on an existing SMTP object to test if it is alive 156 if (!$self->_transport() || !$self->_transport()->reset()) { 157 158 # No usable object, so we create a new one 159 $self->_transport( $self->_init_transport() ); 160 161 } 162 163 return $self->_transport(); 164 165} 166 167sub _new_smtp { 168 my $self = shift; 169 return Net::SMTP->new( @_ ); 170} 171 172sub _cfg_to_smtp_new_args { 173 my $self = shift; 174 my $cfg = shift; 175 my %smtp = ( 176 Host => $cfg->{host} || 'localhost', 177 Hello => $cfg->{helo} || Net::Domain::hostfqdn, 178 ); 179 $smtp{'Port'} = $cfg->{port} if ($cfg->{port}); 180 $smtp{'User'} = $cfg->{username} if ($cfg->{username}); 181 $smtp{'Password'} = $cfg->{password} if ($cfg->{password}); 182 $smtp{'Timeout'} = $cfg->{timeout} if ($cfg->{timeout}); 183 $smtp{'Debug'} = 1 if ($cfg->{debug}); 184 return %smtp; 185} 186 187sub _init_transport { 188 my $self = shift; 189 190 ##! 8: 'creating Net::SMTP transport' 191 my $cfg = CTX('config')->get_hash( $self->config() . '.backend' ); 192 193 my %smtp = $self->_cfg_to_smtp_new_args($cfg); 194 my $transport = $self->_new_smtp( %smtp ); 195 196 # Net::SMTP returns undef if it can not reach the configured socket 197 if (!$transport || !ref $transport) { 198 CTX('log')->system()->fatal(sprintf("Failed creating smtp transport (host: %s, user: %s)", $smtp{Host}, $smtp{User})); 199 return undef; 200 } 201 202 if($cfg->{starttls}) { 203 $transport->starttls; 204 } 205 206 if($cfg->{username}) { 207 if(!$cfg->{password}) { 208 CTX('log')->log( 209 MESSAGE => sprintf("Empty password or no password provided (for user %s)", $cfg->{username}), 210 PRIORITY => "error", 211 FACILITY => [ "system", "monitor" ] 212 ); 213 $transport->quit; 214 return undef; 215 } 216 CTX('log')->log( 217 MESSAGE => sprintf("Authenticating to server (user %s)", $cfg->{username}), 218 PRIORITY => "debug", 219 FACILITY => [ "system", "monitor" ] 220 ); 221 222 if(!$transport->auth($cfg->{username}, $cfg->{password})) { 223 CTX('log')->log( 224 MESSAGE => sprintf("SMTP SASL authentication failed (user: %s, error: %s)", $cfg->{username}, $transport->message), 225 PRIORITY => "error", 226 FACILITY => [ "system", "monitor" ] 227 ); 228 $transport->quit; 229 return undef; 230 } 231 } 232 $self->is_smtp_open(1); 233 return $transport; 234 235} 236 237sub _init_default_envelope { 238 my $self = shift; 239 240 my $envelope = CTX('config')->get_hash( $self->config() . '.default' ); 241 242 if ($self->use_html() && $envelope->{images}) { 243 # Depending on the connector this is already a hash 244 $envelope->{images} = CTX('config')->get_hash( $self->config() . '.default.images' ) if (ref $envelope->{images} ne 'HASH'); 245 } 246 247 ##! 8: 'Envelope data ' . Dumper $envelope 248 249 return $envelope; 250} 251 252sub _init_use_html { 253 254 my $self = shift; 255 256 ##! 8: 'Test for HTML ' 257 my $html = CTX('config')->get( $self->config() . '.backend.use_html' ); 258 259 if ($html) { 260 261 # Try to load the Mime class 262 eval "use MIME::Entity;1"; 263 if ($EVAL_ERROR) { 264 CTX('log')->system()->error("Initialization of MIME::Entity failed, falling back to plain text"); 265 return 0; 266 } else { 267 return 1; 268 } 269 } 270 return 0; 271} 272 273sub _init_smime { 274 275 my $self = shift; 276 277 my $cfg = CTX('config')->get_hash( $self->config() . '.smime' ); 278 279 if (!$cfg) { 280 return; 281 } 282 283 eval "use Crypt::SMIME;1"; 284 if ($EVAL_ERROR) { 285 CTX('log')->system()->fatal("Initialization of Crypt::SMIME failed!"); 286 OpenXPKI::Exception->throw( 287 message => "Initialization of Crypt::SMIME failed!", 288 ); 289 } 290 require Crypt::SMIME; 291 292 my $smime; 293 if ($cfg->{certificate_p12_file}) { 294 295 my $pkcs12 = OpenXPKI::FileUtils->read_file( $cfg->{certificate_p12_file} ); 296 $smime = Crypt::SMIME->new()->setPrivateKeyPkcs12($pkcs12, $cfg->{certificate_key_password}); 297 298 CTX('log')->system()->debug("Enable SMIME signer for notification backend (PKCS12)"); 299 300 301 } elsif( $cfg->{certificate_key_file} ) { 302 303 my $key= OpenXPKI::FileUtils->read_file( $cfg->{certificate_key_file} ); 304 my $cert = OpenXPKI::FileUtils->read_file( $cfg->{certificate_file} ); 305 $smime = Crypt::SMIME->new()->setPrivateKey( $key, $cert, $cfg->{certificate_key_password} ); 306 307 CTX('log')->system()->debug("Enable SMIME signer for notification backend"); 308 309 310 } 311 312 return $smime; 313 314} 315 316=head1 Functions 317 318=head2 notify 319 320see @OpenXPKI::Server::Notification::Base 321 322=cut 323sub notify { 324 325 ##! 1: 'start' 326 327 my $self = shift; 328 my $args = shift; 329 330 my $msg = $args->{MESSAGE}; 331 my $token = $args->{TOKEN}; 332 333 my $template_vars = $args->{VARS}; 334 335 my $msgconfig = $self->config().'.message.'.$msg; 336 337 ##! 1: 'Config Path ' . $msgconfig 338 339 # Test if there is an entry for this kind of message 340 my @handles = CTX('config')->get_keys( $msgconfig ); 341 342 ##! 16: 'Found handles ' . Dumper @handles 343 344 if (!@handles) { 345 CTX('log')->system()->debug("No notifcations to send for $msgconfig"); 346 347 return undef; 348 } 349 350 my $default_envelope = $self->default_envelope(); 351 352 my @failed; 353 354 # Walk through the handles 355 MAIL_HANDLE: 356 foreach my $handle (@handles) { 357 358 my %vars = %{$template_vars}; 359 360 # Fetch the config 361 my $cfg = CTX('config')->get_hash( "$msgconfig.$handle" ); 362 363 # look for images if using HTML 364 if ($self->use_html() && $cfg->{images}) { 365 # Depending on the connector this is already a hash 366 $cfg->{images} = CTX('config')->get_hash( "$msgconfig.$handle.images" ) if (ref $cfg->{images} ne 'HASH'); 367 } 368 369 ##! 16: 'Local config ' . Dumper $cfg 370 371 # Merge with default envelope 372 foreach my $key (keys %{$default_envelope}) { 373 $cfg->{$key} = $default_envelope->{$key} if (!defined $cfg->{$key}); 374 } 375 376 # templating for reply-to 377 $cfg->{reply} = $self->_render_template( $cfg->{reply}, \%vars ) if ($cfg->{reply}); 378 379 ##! 8: 'Process handle ' . $handle 380 381 # Look if there is info from previous notifications 382 # Persisted information includes: 383 # * to: Receipient address 384 # * cc: CC-Receipient, array of address 385 # * prefix: subject prefix (aka Ticket-Id) 386 my $pi = $token->{$handle}; 387 if (!defined $pi) { 388 $pi = { 389 prefix => '', 390 to => '', 391 cc => [], 392 }; 393 394 # Create prefix 395 if (my $prefix = $cfg->{prefix}) { 396 $pi->{prefix} = $self->_render_template($prefix, \%vars); 397 ##! 32: 'Creating new prefix ' . $pi->{prefix} 398 } 399 400 # Receipient 401 $pi->{to} = $self->_render_receipient( $cfg->{to}, \%vars ); 402 ##! 32: 'Got new rcpt ' . $pi->{to} 403 404 # CC-Receipient 405 my @cclist; 406 407 ##! 32: 'Building new cc list' 408 # explicit from configuration, can be a comma sep. list 409 if(!$cfg->{cc}) { 410 # noop 411 } elsif (ref $cfg->{cc} eq '') { 412 my $cc = $self->_render_template( $cfg->{cc}, \%vars ); 413 ##! 32: 'Parsed cc ' . $cc 414 # split at comma with optional whitespace and filter out 415 # strings that do not look like a mail address 416 @cclist = map { $_ =~ /^[\w\.-]+\@[\w\.-]+$/ ? $_ : () } split(/\s*,\s*/, $cc); 417 } elsif (ref $cfg->{cc} eq 'ARRAY') { 418 ##! 32: 'CC from array ' . Dumper $cfg->{cc} 419 foreach my $cc (@{$cfg->{cc}}) { 420 my $rcpt = $self->_render_receipient( $cc, \%vars ); 421 ##! 32: 'New cc rcpt: ' . $cc . ' -> ' . $rcpt 422 push @cclist, $rcpt if($rcpt); 423 } 424 } 425 426 $pi->{cc} = \@cclist; 427 ##! 32: 'New cclist ' . Dumper $pi->{cc} 428 429 # Write back info to be persisted 430 $token->{$handle} = $pi; 431 } 432 433 ##! 16: 'Persisted info: ' . Dumper $pi 434 # Copy PI to vars 435 foreach my $key (keys %{$pi}) { 436 $vars{$key} = $pi->{$key}; 437 } 438 439 if (!$vars{to}) { 440 CTX('log')->system()->warn("Failed sending notification - no receipient"); 441 442 push @failed, $handle; 443 next MAIL_HANDLE; 444 } 445 446 if ($self->use_html()) { 447 $self->_send_html( $cfg, \%vars ) || push @failed, $handle; 448 } else { 449 $self->_send_plain( $cfg, \%vars ) || push @failed, $handle; 450 } 451 452 } 453 454 $self->failed( \@failed ); 455 456 $self->_cleanup(); 457 458 return $token; 459 460} 461 462=head2 463 464=cut 465 466sub _render_receipient { 467 468 ##! 1: 'Start' 469 my $self = shift; 470 my $template = shift; 471 my $vars = shift; 472 473 ##! 16: $template 474 ##! 64: $vars 475 476 if (!$template) { 477 CTX('log')->system()->warn("No receipient adress or template given"); 478 return; 479 } 480 481 my $rcpt = $self->_render_template( $template, $vars ); 482 483 # trim whitespace 484 $rcpt =~ s/\s+//; 485 486 if (!$rcpt) { 487 CTX('log')->system()->warn("Receipient address is empty after render!"); 488 CTX('log')->system()->debug("Template was $template"); 489 return; 490 } 491 492 if ($rcpt !~ /^[\w\.-]+\@[\w\.-]+$/) { 493 ##! 8: 'This is not an address ' . $rcpt 494 CTX('log')->system()->warn("Receipient address is not properly formatted: $rcpt"); 495 CTX('log')->system()->debug("Template was $template"); 496 return; 497 } 498 499 return $rcpt; 500 501} 502 503=head2 _send_plain 504 505Send the message using Net::SMTP 506 507=cut 508sub _send_plain { 509 510 my $self = shift; 511 my $cfg = shift; 512 my $vars = shift; 513 514 my $output = $self->_render_template_file( $cfg->{template}.'.txt', $vars ); 515 516 if (!$output) { 517 CTX('log')->system()->error("Mail body is empty ($cfg->{template})"); 518 519 return 0; 520 } 521 522 my $subject = $self->_render_template( $cfg->{subject}, $vars ); 523 if (!$subject) { 524 CTX('log')->system()->error("Mail subject is empty ($cfg->{template})"); 525 526 return 0; 527 } 528 529 # Now send the message 530 # For Net::SMTP we need to build the full message including the header part 531 my $smtpmsg = "User-Agent: OpenXPKI Notification Service using Net::SMTP\n"; 532 $smtpmsg .= "Date: " . DateTime->now()->strftime("%a, %d %b %Y %H:%M:%S %z\n"); 533 $smtpmsg .= "OpenXPKI-Thread-Id: " . (defined $vars->{'thread'} ? $vars->{'thread'} : 'undef') . "\n"; 534 $smtpmsg .= "From: " . $cfg->{from} . "\n"; 535 $smtpmsg .= "To: " . $vars->{to} . "\n"; 536 $smtpmsg .= "Cc: " . join(",", @{$vars->{cc}}) . "\n" if ($vars->{cc}); 537 $smtpmsg .= "Reply-To: " . $cfg->{reply} . "\n" if ($cfg->{reply}); 538 $smtpmsg .= "Subject: $vars->{prefix} $subject\n"; 539 $smtpmsg .= "\n$output"; 540 541 ##! 64: "SMTP Msg --------------------\n$smtpmsg\n ----------------------------------"; 542 543 my $smtp = $self->transport(); 544 if (!$smtp) { 545 CTX('log')->system()->error(sprintf("Failed sending notification - no smtp transport")); 546 547 return undef; 548 } 549 550 ##! 128: 'SMTP Transport ' . Dumper $smtp 551 552 $smtp->mail( $cfg->{from} ); 553 $smtp->to( $vars->{to} ); 554 555 foreach my $cc (@{$vars->{cc}}) { 556 $smtp->to( $cc ); 557 } 558 559 $smtp->data(); 560 561 # Sign if SMIME is set up 562 563 if (my $smime = $self->_smime()) { 564 $smtpmsg = $smime->sign($smtpmsg); 565 } 566 567 $smtp->datasend($smtpmsg); 568 569 if( !$smtp->dataend() ) { 570 CTX('log')->system()->warn(sprintf("Failed sending notification (%s, %s)", $vars->{to}, $subject)); 571 return 0; 572 } 573 CTX('log')->system()->info(sprintf("Notification was send (%s, %s)", $vars->{to}, $subject)); 574 575 576 return 1; 577 578} 579 580=head2 _send_html 581 582Send the message using MIME::Tools 583 584=cut 585 586sub _send_html { 587 588 my $self = shift; 589 my $cfg = shift; 590 my $vars = shift; 591 592 require MIME::Entity; 593 594 # Parse the templates - txt and html 595 my $plain = $self->_render_template_file( $cfg->{template}.'.txt', $vars ); 596 my $html = $self->_render_template_file( $cfg->{template}.'.html', $vars ); 597 598 if (!$plain && !$html) { 599 CTX('log')->system()->error("Both mail parts are empty ($cfg->{template})"); 600 601 return 0; 602 } 603 604 # Parse the subject 605 my $subject = $self->_render_template($cfg->{subject}, $vars); 606 if (!$subject) { 607 CTX('log')->system()->error("Mail subject is empty ($cfg->{template})"); 608 609 return 0; 610 } 611 612 my @args = ( 613 From => Encode::encode_utf8($cfg->{from}), 614 To => Encode::encode_utf8($vars->{to}), 615 Subject => Encode::encode_utf8("$vars->{prefix} $subject"), 616 Type =>'multipart/alternative', 617 Charset => 'UTF-8', 618 ); 619 620 push @args, (Cc => join(",", @{$vars->{cc}})) if ($vars->{cc}); 621 push @args, ("Reply-To" => $cfg->{reply}) if ($cfg->{reply}); 622 623 ##! 16: 'Building with args: ' . Dumper @args 624 625 my $msg = MIME::Entity->build( @args ); 626 627 # Plain part 628 if ($plain) { 629 ##! 16: ' Attach plain text' 630 $msg->attach( 631 Type =>'text/plain', 632 Data => Encode::encode_utf8($plain) 633 ); 634 } 635 636 ##! 16: 'base created' 637 638 # look for images - makes the mail a bit complicated as we need to build a second mime container 639 if ($html && $cfg->{images}) { 640 641 ##! 16: ' Multipart html + image' 642 643 my $html_part = MIME::Entity->build( 644 'Type' => 'multipart/related', 645 ); 646 647 # The HTML Body 648 $html_part->attach( 649 Type =>'text/html', 650 Data => Encode::encode_utf8($html) 651 ); 652 653 # The hash contains the image id and the filename 654 ATTACH_IMAGE: 655 foreach my $imgid (keys(%{$cfg->{images}})) { 656 my $imgfile = $self->template_dir().'images/'.$cfg->{images}->{$imgid}; 657 if (! -e $imgfile) { 658 CTX('log')->system()->error(sprintf("HTML Notify - imagefile not found (%s)", $imgfile)); 659 660 next ATTACH_IMAGE; 661 } 662 663 $cfg->{images}->{$imgid} =~ /\.(gif|png|jpg)$/i; 664 my $mime = lc($1); 665 666 if (!$mime) { 667 CTX('log')->system()->error(sprintf("HTML Notify - invalid image extension", $imgfile)); 668 669 next ATTACH_IMAGE; 670 } 671 672 $html_part->attach( 673 Type => 'image/'.$mime, 674 Id => $imgid, 675 Path => $imgfile, 676 ); 677 } 678 679 $msg->add_part($html_part); 680 681 } elsif ($html) { 682 ##! 16: ' html without image' 683 ## Add the html part: 684 $msg->attach( 685 Type =>'text/html', 686 Data => Encode::encode_utf8($html) 687 ); 688 } 689 690 # a reusable Net::SMTP object 691 my $smtp = $self->transport(); 692 693 if (!$smtp) { 694 CTX('log')->system()->error(sprintf("Failed sending notification - no smtp transport")); 695 696 return undef; 697 } 698 699 my $res; 700 # Sign if SMIME is set up 701 702 if (my $smime = $self->_smime()) { 703 704 $smtp->mail( $cfg->{from} ); 705 $smtp->to( $vars->{to} ); 706 foreach my $cc (@{$vars->{cc}}) { 707 $smtp->to( $cc ); 708 } 709 $smtp->data(); 710 $smtp->datasend( $smime->sign( $msg->as_string() ) ); 711 $res = $smtp->dataend(); 712 713 } else { 714 715 # Host accepts a Net::SMTP object 716 # @res is the list of receipients processed, empty on error 717 $res = $msg->smtpsend( Host => $smtp, MailFrom => $cfg->{from} ); 718 } 719 720 if(!$res) { 721 CTX('log')->system()->error(sprintf("Failed sending notification (%s, %s)", $vars->{to}, $subject)); 722 723 return 0; 724 } 725 726 CTX('log')->system()->info(sprintf("Notification was send (%s, %s)", $vars->{to}, $subject)); 727 728 729 return 1; 730 731} 732 733sub _cleanup { 734 735 my $self = shift; 736 737 if ($self->is_smtp_open()) { 738 $self->transport()->quit(); 739 740 } 741 $self->_transport( undef ); 742 743 return; 744} 745 7461; 747 748__END__ 749