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