# --
# Copyright (C) 2001-2021 OTRS AG, https://otrs.com/
# Copyright (C) 2021 Znuny GmbH, https://znuny.org/
# --
# This software comes with ABSOLUTELY NO WARRANTY. For details, see
# the enclosed file COPYING for license information (GPL). If you
# did not receive this file, see https://www.gnu.org/licenses/gpl-3.0.txt.
# --

package Kernel::System::Ticket::Article::Backend::MIMEBase;

use strict;
use warnings;
use utf8;

use parent 'Kernel::System::Ticket::Article::Backend::Base';

use Kernel::System::EmailParser;
use Kernel::System::VariableCheck qw(:all);

our @ObjectDependencies = (
    'Kernel::Config',
    'Kernel::System::CommunicationChannel',
    'Kernel::System::DB',
    'Kernel::System::DateTime',
    'Kernel::System::HTMLUtils',
    'Kernel::System::Log',
    'Kernel::System::Main',
    'Kernel::System::Ticket',
    'Kernel::System::Ticket::Article',
    'Kernel::System::Ticket::Article::Backend::Email',
    'Kernel::System::User',
);

=head1 NAME

Kernel::System::Ticket::Article::Backend::MIMEBase - base class for all C<MIME> based article backends

=head1 DESCRIPTION

This is a base class for article data in C<MIME> format and should not be instantiated directly.
Always get real backend instead, i.e. C<Email>, C<Phone> or C<Internal>.

Basic article data is always stored in a database table, but extended data uses configured article
storage backend. For plain text representation of the message, use C<Body> field. For original
message with email headers, use L</ArticlePlain()> method to retrieve it from storage backend.
Attachments are handled by the storage backends, and can be retrieved via L</ArticleAttachment()>.

Inherits from L<Kernel::System::Ticket::Article::Backend::Base>.

See also L<Kernel::System::Ticket::Article::Backend::MIMEBase::Base> and
L<Kernel::System::Ticket::Article::Backend::Email>.

=head1 PUBLIC INTERFACE

=head2 new()

Don't instantiate this class directly, get instances of the real backends instead:

    my $ArticleBackendObject = $Kernel::OM->Get('Kernel::System::Ticket::Article')->BackendForChannel(ChannelName => 'Email');

=cut

sub new {
    my ( $Type, %Param ) = @_;

    # Call constructor of the base class.
    my $Self = $Type->SUPER::new(%Param);

    # 0=off; 1=on;
    $Self->{Debug} = $Param{Debug} || 0;

    # Persistent for this object's lifetime so that we can have article objects with different storage modules.
    $Self->{ArticleStorageModule} = $Param{ArticleStorageModule}
        || $Kernel::OM->Get('Kernel::Config')->Get('Ticket::Article::Backend::MIMEBase::ArticleStorage')
        || 'Kernel::System::Ticket::Article::Backend::MIMEBase::ArticleStorageDB';

    return $Self;
}

=head2 ArticleCreate()

Create a MIME article.

    my $ArticleID = $ArticleBackendObject->ArticleCreate(
        TicketID             => 123,                              # (required)
        SenderTypeID         => 1,                                # (required)
                                                                  # or
        SenderType           => 'agent',                          # (required) agent|system|customer
        IsVisibleForCustomer => 1,                                # (required) Is article visible for customer?
        UserID               => 123,                              # (required)

        From           => 'Some Agent <email@example.com>',       # not required but useful
        To             => 'Some Customer A <customer-a@example.com>', # not required but useful
        Cc             => 'Some Customer B <customer-b@example.com>', # not required but useful
        Bcc            => 'Some Customer C <customer-c@example.com>', # not required but useful
        ReplyTo        => 'Some Customer B <customer-b@example.com>', # not required
        Subject        => 'some short description',               # not required but useful
        Body           => 'the message text',                     # not required but useful
        MessageID      => '<asdasdasd.123@example.com>',          # not required but useful
        InReplyTo      => '<asdasdasd.12@example.com>',           # not required but useful
        References     => '<asdasdasd.1@example.com> <asdasdasd.12@example.com>', # not required but useful
        ContentType    => 'text/plain; charset=ISO-8859-15',      # or optional Charset & MimeType
        HistoryType    => 'OwnerUpdate',                          # EmailCustomer|Move|AddNote|PriorityUpdate|WebRequestCustomer|...
        HistoryComment => 'Some free text!',
        Attachment => [
            {
                Content     => $Content,
                ContentType => $ContentType,
                Filename    => 'lala.txt',
            },
            {
                Content     => $Content,
                ContentType => $ContentType,
                Filename    => 'lala1.txt',
            },
        ],
        NoAgentNotify    => 0,                                      # if you don't want to send agent notifications
        AutoResponseType => 'auto reply',                           # auto reject|auto follow up|auto reply/new ticket|auto remove

        ForceNotificationToUserID   => [ 1, 43, 56 ],               # if you want to force somebody
        ExcludeNotificationToUserID => [ 43,56 ],                   # if you want full exclude somebody from notifications,
                                                                    # will also be removed in To: line of article,
                                                                    # higher prio as ForceNotificationToUserID
        ExcludeMuteNotificationToUserID => [ 43,56 ],               # the same as ExcludeNotificationToUserID but only the
                                                                    # sending gets muted, agent will still shown in To:
                                                                    # line of article
    );

Example with "Charset & MimeType" and no "ContentType".

    my $ArticleID = $ArticleBackendObject->ArticleCreate(
        TicketID             => 123,                                 # (required)
        SenderType           => 'agent',                             # (required) agent|system|customer
        IsVisibleForCustomer => 1,                                   # (required) Is article visible for customer?

        From             => 'Some Agent <email@example.com>',       # not required but useful
        To               => 'Some Customer A <customer-a@example.com>', # not required but useful
        Subject          => 'some short description',               # required
        Body             => 'the message text',                     # required
        Charset          => 'ISO-8859-15',
        MimeType         => 'text/plain',
        HistoryType      => 'OwnerUpdate',                          # EmailCustomer|Move|AddNote|PriorityUpdate|WebRequestCustomer|...
        HistoryComment   => 'Some free text!',
        UserID           => 123,
        UnlockOnAway     => 1,                                      # Unlock ticket if owner is away
    );

Events:
    ArticleCreate

=cut

sub ArticleCreate {
    my ( $Self, %Param ) = @_;

    my $IncomingTime = $Kernel::OM->Create('Kernel::System::DateTime')->ToEpoch();

    my $ArticleContentPath = $Kernel::OM->Get( $Self->{ArticleStorageModule} )->BuildArticleContentPath();

    # create ArticleContentPath
    if ( !$ArticleContentPath ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => 'Need ArticleContentPath!'
        );
        return;
    }

    my $ArticleObject = $Kernel::OM->Get('Kernel::System::Ticket::Article');

    # Lookup if no ID is passed.
    if ( $Param{SenderType} && !$Param{SenderTypeID} ) {
        $Param{SenderTypeID} = $ArticleObject->ArticleSenderTypeLookup( SenderType => $Param{SenderType} );
    }

    # check needed stuff
    for my $Needed (qw(TicketID UserID SenderTypeID HistoryType HistoryComment)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!"
            );
            return;
        }
    }

    if ( !defined $Param{IsVisibleForCustomer} ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => "Need IsVisibleForCustomer!"
        );
        return;
    }

    # check ContentType vs. Charset & MimeType
    if ( !$Param{ContentType} ) {
        for my $Item (qw(Charset MimeType)) {
            if ( !$Param{$Item} ) {
                $Kernel::OM->Get('Kernel::System::Log')->Log(
                    Priority => 'error',
                    Message  => "Need $Item!"
                );
                return;
            }
        }
        $Param{ContentType} = "$Param{MimeType}; charset=$Param{Charset}";
    }
    else {
        for my $Item (qw(ContentType)) {
            if ( !$Param{$Item} ) {
                $Kernel::OM->Get('Kernel::System::Log')->Log(
                    Priority => 'error',
                    Message  => "Need $Item!"
                );
                return;
            }
        }
        $Param{Charset} = '';
        if ( $Param{ContentType} =~ /charset\s*=\s*/i ) {
            $Param{Charset} = $Param{ContentType};
            $Param{Charset} =~ s/.+?charset\s*=\s*("|'|)(\w+)/$2/gi;
            $Param{Charset} =~ s/"|'//g;
            $Param{Charset} =~ s/(.+?);.*/$1/g;

        }
        $Param{MimeType} = '';
        if ( $Param{ContentType} =~ /^(\w+\/\w+)/i ) {
            $Param{MimeType} = $1;
            $Param{MimeType} =~ s/"|'//g;
        }
    }

    my $TicketObject = $Kernel::OM->Get('Kernel::System::Ticket');

    # for the event handler, before any actions have taken place
    my %OldTicketData = $TicketObject->TicketGet(
        TicketID      => $Param{TicketID},
        DynamicFields => 1,
    );

    # get html utils object
    my $HTMLUtilsObject = $Kernel::OM->Get('Kernel::System::HTMLUtils');

    # add 'no body' if there is no body there!
    my @AttachmentConvert;
    if ( !defined $Param{Body} ) {    # allow '0' as body
        $Param{Body} = '';
    }

    # process html article
    elsif ( $Param{MimeType} =~ /text\/plain/i ) {

        # Delete illegal surrogates in Body
        $Param{Body} =~ s/\p{Surrogate}//g;
    }
    elsif ( $Param{MimeType} =~ /text\/html/i ) {

        # Delete illegal surrogates in Body
        $Param{Body} =~ s/\p{Surrogate}//g;

        # add html article as attachment
        my $Attach = {
            Content     => $Param{Body},
            ContentType => "text/html; charset=\"$Param{Charset}\"",
            Filename    => 'file-2',
        };
        push @AttachmentConvert, $Attach;

        # get ASCII body
        $Param{MimeType} = 'text/plain';
        $Param{ContentType} =~ s/html/plain/i;
        $Param{Body} = $HTMLUtilsObject->ToAscii(
            String => $Param{Body},
        );
    }
    elsif ( $Param{MimeType} && $Param{MimeType} eq "application/json" ) {

        # Keep JSON body unchanged
    }

    # if body isn't text, attach body as attachment (mostly done by OE) :-/
    elsif ( $Param{MimeType} && $Param{MimeType} !~ /\btext\b/i ) {

        # Add non-text as an attachment. Try to deduce the filename from ContentType or ContentDisposition headers.
        #   Please see bug#13644 for more information.
        my $FileName = 'unknown';
        if (
            $Param{ContentType} =~ /name="(.+?)"/i
            || (
                defined $Param{ContentDisposition}
                && $Param{ContentDisposition} =~ /name="(.+?)"/i
            )
            )
        {
            $FileName = $1;
        }
        my $Attach = {
            Content     => $Param{Body},
            ContentType => $Param{ContentType},
            Filename    => $FileName,
        };
        push @{ $Param{Attachment} }, $Attach;

        # set ASCII body
        $Param{MimeType}           = 'text/plain';
        $Param{ContentType}        = 'text/plain';
        $Param{Body}               = '- no text message => see attachment -';
        $Param{OrigHeader}->{Body} = $Param{Body};
    }

    else {
        # Delete illegal surrogates in Body
        $Param{Body} =~ s/\p{Surrogate}//g;

        # Fix some bad stuff from some browsers (Opera)!
        $Param{Body} =~ s/(\n\r|\r\r\n|\r\n)/\n/g;
    }

    # strip not wanted stuff
    for my $Attribute (qw(From To Cc Bcc Subject MessageID InReplyTo References ReplyTo)) {
        if ( defined $Param{$Attribute} ) {
            $Param{$Attribute} =~ s/[\p{Surrogate}\n\r]//g;
        }
        else {
            $Param{$Attribute} = '';
        }
    }
    ATTRIBUTE:
    for my $Attribute (qw(MessageID)) {
        next ATTRIBUTE if !$Param{$Attribute};
        $Param{$Attribute} = substr( $Param{$Attribute}, 0, 3800 );
    }

    # Check if this is the first article (for notifications).
    my @Articles     = $ArticleObject->ArticleList( TicketID => $Param{TicketID} );
    my $FirstArticle = scalar @Articles ? 0 : 1;

    my $MainObject = $Kernel::OM->Get('Kernel::System::Main');

    # calculate MD5 of Message ID
    if ( $Param{MessageID} ) {
        $Param{MD5} = $MainObject->MD5sum( String => $Param{MessageID} );
    }

    # Generate unique fingerprint for searching created article in database to prevent race conditions
    my $RandomString = $MainObject->GenerateRandomString(
        Length => 32,
    );
    my $ArticleInsertFingerprint = $$ . '-' . $RandomString . '-' . ( $Param{MessageID} // '' );

    # get database object
    my $DBObject = $Kernel::OM->Get('Kernel::System::DB');

    # Create meta article.
    my $ArticleID = $Self->_MetaArticleCreate(
        TicketID               => $Param{TicketID},
        SenderTypeID           => $Param{SenderTypeID},
        IsVisibleForCustomer   => $Param{IsVisibleForCustomer},
        CommunicationChannelID => $Self->ChannelIDGet(),
        UserID                 => $Param{UserID},
    );
    if ( !$ArticleID ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => "Can't create meta article (TicketID=$Param{TicketID})!",
        );
        return;
    }

    my $UserObject = $Kernel::OM->Get('Kernel::System::User');

    # Check if there are additional To's from InvolvedAgent and InformAgent.
    if ( $Param{ForceNotificationToUserID} && ref $Param{ForceNotificationToUserID} eq 'ARRAY' ) {
        my $NewTo = '';
        USER:
        for my $UserID ( @{ $Param{ForceNotificationToUserID} } ) {

            next USER if $UserID == 1;
            next USER if grep { $UserID eq $_ } @{ $Param{ExcludeNotificationToUserID} };

            my %UserData = $UserObject->GetUserData(
                UserID => $UserID,
                Valid  => 1,
            );

            next USER if !%UserData;

            if ( $Param{To} || $NewTo ) {
                $NewTo .= ', ';
            }
            $NewTo .= "\"$UserData{UserFirstname} $UserData{UserLastname}\" <$UserData{UserEmail}>";
        }
        $Param{To} .= $NewTo;
    }

    return if !$DBObject->Do(
        SQL => '
            INSERT INTO article_data_mime (
                article_id, a_from, a_reply_to, a_to, a_cc, a_bcc, a_subject, a_message_id,
                a_message_id_md5, a_in_reply_to, a_references, a_content_type, a_body,
                incoming_time, content_path, create_time, create_by, change_time, change_by)
            VALUES (
                ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, current_timestamp, ?, current_timestamp, ?
            )
        ',
        Bind => [
            \$ArticleID, \$Param{From}, \$Param{ReplyTo}, \$Param{To}, \$Param{Cc}, \$Param{Bcc},
            \$Param{Subject},
            \$ArticleInsertFingerprint,    # just for next search; will be updated with correct MessageID
            \$Param{MD5},  \$Param{InReplyTo}, \$Param{References},  \$Param{ContentType},
            \$Param{Body}, \$IncomingTime,     \$ArticleContentPath, \$Param{UserID}, \$Param{UserID},
        ],
    );

    # Get article data ID.
    return if !$DBObject->Prepare(
        SQL => '
            SELECT id FROM article_data_mime
            WHERE article_id = ?
                AND a_message_id = ?
                AND incoming_time = ?
            ORDER BY id DESC
        ',
        Bind  => [ \$ArticleID, \$ArticleInsertFingerprint, \$IncomingTime, ],
        Limit => 1,
    );

    my $ArticleDataID;
    while ( my @Row = $DBObject->FetchrowArray() ) {
        $ArticleDataID = $Row[0];
    }

    # Return if article data record was not created.
    if ( !$ArticleDataID ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  =>
                "Can't store article data (TicketID=$Param{TicketID}, ArticleID=$ArticleID, MessageID=$Param{MessageID})!",
        );
        return;
    }

    # Save correct Message-ID now.
    return if !$DBObject->Do(
        SQL  => 'UPDATE article_data_mime SET a_message_id = ? WHERE id = ?',
        Bind => [ \$Param{MessageID}, \$ArticleDataID, ],
    );

    # check for base64 encoded images in html body and upload them
    for my $Attachment (@AttachmentConvert) {

        if (
            $Attachment->{ContentType} =~ /^text\/html; charset\s*=\s*"$Param{Charset}"$/i
            && $Attachment->{Filename} eq 'file-2'
            )
        {
            $HTMLUtilsObject->EmbeddedImagesExtract(
                DocumentRef    => \$Attachment->{Content},
                AttachmentsRef => \@AttachmentConvert,
            );
        }
    }

    # add converted attachments
    for my $Attachment (@AttachmentConvert) {
        $Kernel::OM->Get( $Self->{ArticleStorageModule} )->ArticleWriteAttachment(
            %{$Attachment},
            ArticleID => $ArticleID,
            UserID    => $Param{UserID},
        );
    }

    # add attachments
    if ( $Param{Attachment} ) {
        for my $Attachment ( @{ $Param{Attachment} } ) {
            $Kernel::OM->Get( $Self->{ArticleStorageModule} )->ArticleWriteAttachment(
                %{$Attachment},
                ArticleID => $ArticleID,
                UserID    => $Param{UserID},
            );
        }
    }

    $ArticleObject->_ArticleCacheClear(
        TicketID => $Param{TicketID},
    );

    # add history row
    $TicketObject->HistoryAdd(
        ArticleID    => $ArticleID,
        TicketID     => $Param{TicketID},
        CreateUserID => $Param{UserID},
        HistoryType  => $Param{HistoryType},
        Name         => $Param{HistoryComment},
    );

    my $ConfigObject = $Kernel::OM->Get('Kernel::Config');

    # unlock ticket if the owner is away (and the feature is enabled)
    if (
        $Param{UnlockOnAway}
        && $OldTicketData{Lock} eq 'lock'
        && $ConfigObject->Get('Ticket::UnlockOnAway')
        )
    {
        my %OwnerInfo = $UserObject->GetUserData(
            UserID => $OldTicketData{OwnerID},
        );

        if ( $OwnerInfo{OutOfOfficeMessage} ) {
            $TicketObject->TicketLockSet(
                TicketID => $Param{TicketID},
                Lock     => 'unlock',
                UserID   => $Param{UserID},
            );
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'notice',
                Message  =>
                    "Ticket [$OldTicketData{TicketNumber}] unlocked, current owner is out of office!",
            );
        }
    }

    $ArticleObject->ArticleSearchIndexBuild(
        TicketID  => $Param{TicketID},
        ArticleID => $ArticleID,
        UserID    => 1,
    );

    # event
    $Self->EventHandler(
        Event => 'ArticleCreate',
        Data  => {
            ArticleID     => $ArticleID,
            TicketID      => $Param{TicketID},
            OldTicketData => \%OldTicketData,
        },
        UserID => $Param{UserID},
    );

    # reset unlock if needed
    if ( !$Param{SenderType} ) {
        $Param{SenderType} = $ArticleObject->ArticleSenderTypeLookup( SenderTypeID => $Param{SenderTypeID} );
    }

    # reset unlock time if customer sent an update
    if ( $Param{SenderType} eq 'customer' ) {

        # Check if previous sender was an agent.
        my $AgentSenderTypeID  = $ArticleObject->ArticleSenderTypeLookup( SenderType => 'agent' );
        my $SystemSenderTypeID = $ArticleObject->ArticleSenderTypeLookup( SenderType => 'system' );
        my @Articles           = $ArticleObject->ArticleList(
            TicketID => $Param{TicketID},
        );

        my $LastSenderTypeID;
        ARTICLE:
        for my $Article ( reverse @Articles ) {
            next ARTICLE if $Article->{ArticleID} eq $ArticleID;
            next ARTICLE if $Article->{SenderTypeID} eq $SystemSenderTypeID;
            $LastSenderTypeID = $Article->{SenderTypeID};
            last ARTICLE;
        }

        if ( $LastSenderTypeID && $LastSenderTypeID == $AgentSenderTypeID ) {
            $TicketObject->TicketUnlockTimeoutUpdate(
                UnlockTimeout => $IncomingTime,
                TicketID      => $Param{TicketID},
                UserID        => $Param{UserID},
            );
        }
    }

    # check if latest article is sent to customer
    elsif ( $Param{SenderType} eq 'agent' ) {
        $TicketObject->TicketUnlockTimeoutUpdate(
            UnlockTimeout => $IncomingTime,
            TicketID      => $Param{TicketID},
            UserID        => $Param{UserID},
        );
    }

    # send auto response
    if ( $Param{AutoResponseType} ) {

        # Check if SendAutoResponse() method exists, before calling it. If it doesn't, get an instance of an additional
        #   email backend first.
        if ( $Self->can('SendAutoResponse') ) {
            $Self->SendAutoResponse(
                OrigHeader           => $Param{OrigHeader},
                TicketID             => $Param{TicketID},
                UserID               => $Param{UserID},
                AutoResponseType     => $Param{AutoResponseType},
                SenderTypeID         => $Param{SenderTypeID},
                IsVisibleForCustomer => $Param{IsVisibleForCustomer},
            );
        }
        else {
            $Kernel::OM->Get('Kernel::System::Ticket::Article::Backend::Email')->SendAutoResponse(
                OrigHeader           => $Param{OrigHeader},
                TicketID             => $Param{TicketID},
                UserID               => $Param{UserID},
                AutoResponseType     => $Param{AutoResponseType},
                SenderTypeID         => $Param{SenderTypeID},
                IsVisibleForCustomer => $Param{IsVisibleForCustomer},
            );
        }
    }

    # send no agent notification!?
    return $ArticleID if $Param{NoAgentNotify};

    my %Ticket = $TicketObject->TicketGet(
        TicketID      => $Param{TicketID},
        DynamicFields => 0,
    );

    # remember agent to exclude notifications
    my @SkipRecipients;
    if ( $Param{ExcludeNotificationToUserID} && ref $Param{ExcludeNotificationToUserID} eq 'ARRAY' )
    {
        for my $UserID ( @{ $Param{ExcludeNotificationToUserID} } ) {
            push @SkipRecipients, $UserID;
        }
    }

    # remember agent to exclude notifications / already sent
    my %DoNotSendMute;
    if (
        $Param{ExcludeMuteNotificationToUserID}
        && ref $Param{ExcludeMuteNotificationToUserID} eq 'ARRAY'
        )
    {
        for my $UserID ( @{ $Param{ExcludeMuteNotificationToUserID} } ) {
            push @SkipRecipients, $UserID;
        }
    }

    my $ExtraRecipients;
    if ( $Param{ForceNotificationToUserID} && ref $Param{ForceNotificationToUserID} eq 'ARRAY' ) {
        $ExtraRecipients = $Param{ForceNotificationToUserID};
    }

    # send agent notification on ticket create
    if (
        $FirstArticle &&
        $Param{HistoryType}
        =~ /^(EmailAgent|EmailCustomer|PhoneCallCustomer|WebRequestCustomer|SystemRequest)$/i
        )
    {
        # trigger notification event
        $Self->EventHandler(
            Event => 'NotificationNewTicket',
            Data  => {
                TicketID              => $Param{TicketID},
                ArticleID             => $ArticleID,
                SenderTypeID          => $Param{SenderTypeID},
                IsVisibleForCustomer  => $Param{IsVisibleForCustomer},
                Queue                 => $Param{Queue},
                Recipients            => $ExtraRecipients,
                SkipRecipients        => \@SkipRecipients,
                CustomerMessageParams => {%Param},
            },
            UserID => $Param{UserID},
        );
    }

    # send agent notification on adding a note
    elsif ( $Param{HistoryType} =~ /^AddNote$/i ) {

        # trigger notification event
        $Self->EventHandler(
            Event => 'NotificationAddNote',
            Data  => {
                TicketID              => $Param{TicketID},
                ArticleID             => $ArticleID,
                SenderTypeID          => $Param{SenderTypeID},
                IsVisibleForCustomer  => $Param{IsVisibleForCustomer},
                Queue                 => $Param{Queue},
                Recipients            => $ExtraRecipients,
                SkipRecipients        => \@SkipRecipients,
                CustomerMessageParams => {},
            },
            UserID => $Param{UserID},
        );
    }

    # send agent notification on follow up
    elsif ( $Param{HistoryType} =~ /^FollowUp$/i ) {

        # trigger notification event
        $Self->EventHandler(
            Event => 'NotificationFollowUp',
            Data  => {
                TicketID              => $Param{TicketID},
                ArticleID             => $ArticleID,
                SenderTypeID          => $Param{SenderTypeID},
                IsVisibleForCustomer  => $Param{IsVisibleForCustomer},
                Queue                 => $Param{Queue},
                Recipients            => $ExtraRecipients,
                SkipRecipients        => \@SkipRecipients,
                CustomerMessageParams => {%Param},
            },
            UserID => $Param{UserID},
        );
    }

    # return ArticleID
    return $ArticleID;
}

=head2 ArticleGet()

Returns single article data.

    my %Article = $ArticleBackendObject->ArticleGet(
        TicketID      => 123,   # (required)
        ArticleID     => 123,   # (required)
        DynamicFields => 1,     # (optional) To include the dynamic field values for this article on the return structure.
        RealNames     => 1,     # (optional) To include the From/To/Cc/Bcc fields with real names.
    );

Returns:

    %Article = (
        TicketID               => 123,
        ArticleID              => 123,
        From                   => 'Some Agent <email@example.com>',
        To                     => 'Some Customer A <customer-a@example.com>',
        Cc                     => 'Some Customer B <customer-b@example.com>',
        Bcc                    => 'Some Customer C <customer-c@example.com>',
        ReplyTo                => 'Some Customer B <customer-b@example.com>',
        Subject                => 'some short description',
        MessageID              => '<asdasdasd.123@example.com>',
        InReplyTo              => '<asdasdasd.12@example.com>',
        References             => '<asdasdasd.1@example.com> <asdasdasd.12@example.com>',
        ContentType            => 'text/plain; charset=ISO-8859-15',
        Body                   => 'the message text',
        SenderTypeID           => 1,
        SenderType             => 'agent',
        CommunicationChannelID => 3,
        CommunicationChannel   => 'Internal',
        IsVisibleForCustomer   => 1,
        IncomingTime           => 1490690026,
        CreateBy               => 1,
        CreateTime             => '2017-03-28 08:33:47',
        Charset                => 'ISO-8859-15',
        MimeType               => 'text/plain',

        # If DynamicFields => 1 was passed, you'll get an entry like this for each dynamic field:
        DynamicField_X => 'value_x',

        # If RealNames => 1 was passed, you'll get fields with contact real names too:
        FromRealname => 'Some Agent',
        ToRealname   => 'Some Customer A',
        CcRealname   => 'Some Customer B',
        BccRealname  => 'Some Customer C',
    );

=cut

sub ArticleGet {
    my ( $Self, %Param ) = @_;

    for my $Item (qw(TicketID ArticleID)) {
        if ( !$Param{$Item} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Item!"
            );
            return;
        }
    }

    # Get meta article.
    my %Article = $Self->_MetaArticleGet(
        ArticleID => $Param{ArticleID},
        TicketID  => $Param{TicketID},
    );
    return if !%Article;

    my %ArticleSenderTypeList = $Kernel::OM->Get('Kernel::System::Ticket::Article')->ArticleSenderTypeList();

    my %CommunicationChannel;
    if ( $Article{CommunicationChannelID} ) {
        %CommunicationChannel = $Kernel::OM->Get('Kernel::System::CommunicationChannel')->ChannelGet(
            ChannelID => $Article{CommunicationChannelID},
        );
    }

    # Email parser object might be used below for its field cleanup methods only.
    my $EmailParser;
    if ( $Param{RealNames} ) {
        $EmailParser = Kernel::System::EmailParser->new(
            Mode => 'Standalone',
        );
    }

    my $DBObject = $Kernel::OM->Get('Kernel::System::DB');

    my $SQL = '
        SELECT sadm.a_from, sadm.a_reply_to, sadm.a_to, sadm.a_cc, sadm.a_bcc, sadm.a_subject,
            sadm.a_message_id, sadm.a_in_reply_to, sadm.a_references, sadm.a_content_type,
            sadm.a_body, sadm.incoming_time
        FROM article_data_mime sadm
        WHERE sadm.article_id = ?
    ';
    my @Bind = ( \$Param{ArticleID} );

    return if !$DBObject->Prepare(
        SQL   => $SQL,
        Bind  => \@Bind,
        Limit => 1,
    );

    my %Data;
    while ( my @Row = $DBObject->FetchrowArray() ) {
        %Data = (
            %Article,
            From                 => $Row[0],
            ReplyTo              => $Row[1],
            To                   => $Row[2],
            Cc                   => $Row[3],
            Bcc                  => $Row[4],
            Subject              => $Row[5],
            MessageID            => $Row[6],
            InReplyTo            => $Row[7],
            References           => $Row[8],
            ContentType          => $Row[9],
            Body                 => $Row[10],
            IncomingTime         => $Row[11],
            SenderType           => $ArticleSenderTypeList{ $Article{SenderTypeID} },
            CommunicationChannel => $CommunicationChannel{ChannelName},
        );

        # Determine charset.
        if ( $Data{ContentType} && $Data{ContentType} =~ /charset\s*=\s*/i ) {
            $Data{Charset} = $Data{ContentType};
            $Data{Charset} =~ s/.+?charset\s*=\s*("|'|)(\w+)/$2/gi;
            $Data{Charset} =~ s/"|'//g;
            $Data{Charset} =~ s/(.+?);.*/$1/g;
        }
        else {
            $Data{Charset} = '';
        }
        $Data{ContentCharset} = $Data{Charset};    # compatibility

        # Determine MIME type.
        if ( $Data{ContentType} && $Data{ContentType} =~ /^(\w+\/\w+)/i ) {
            $Data{MimeType} = $1;
            $Data{MimeType} =~ s/"|'//g;
        }
        else {
            $Data{MimeType} = '';
        }

        RECIPIENT:
        for my $Key (qw(From To Cc Bcc Subject)) {
            next RECIPIENT if !$Data{$Key};

            # Strip unwanted stuff from some fields.
            $Data{$Key} =~ s/\n|\r//g;

            # Skip further processing for subject field.
            next RECIPIENT if $Key eq 'Subject';

            if ( $Param{RealNames} ) {

                # Check if it's a queue.
                if ( $Data{$Key} !~ /@/ ) {
                    $Data{ $Key . 'Realname' } = $Data{$Key};
                    next RECIPIENT;
                }

                # Strip out real names.
                my $Realname = '';
                EMAILADDRESS:
                for my $EmailSplit ( $EmailParser->SplitAddressLine( Line => $Data{$Key} ) ) {
                    my $Name = $EmailParser->GetRealname( Email => $EmailSplit );
                    if ( !$Name ) {
                        $Name = $EmailParser->GetEmailAddress( Email => $EmailSplit );
                    }
                    next EMAILADDRESS if !$Name;
                    if ($Realname) {
                        $Realname .= ', ';
                    }
                    $Realname .= $Name;
                }

                # Add real name lines.
                $Data{ $Key . 'Realname' } = $Realname;
            }
        }
    }

    # Check if we also need to return dynamic field data.
    if ( $Param{DynamicFields} ) {
        my %DataWithDynamicFields = $Self->_MetaArticleDynamicFieldsGet(
            Data => \%Data,
        );
        %Data = %DataWithDynamicFields;
    }

    # Return if content is empty.
    if ( !%Data ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => "No such article (TicketID=$Param{TicketID}, ArticleID=$Param{ArticleID})!",
        );
        return;
    }

    return %Data;
}

=head2 ArticleUpdate()

Update article data.

Note: Keys C<Body>, C<Subject>, C<From>, C<To>, C<Cc>, C<Bcc>, C<ReplyTo>, C<SenderType>, C<SenderTypeID>
and C<IsVisibleForCustomer> are implemented.

    my $Success = $ArticleBackendObject->ArticleUpdate(
        TicketID  => 123,
        ArticleID => 123,
        Key       => 'Body',
        Value     => 'New Body',
        UserID    => 123,
    );

    my $Success = $ArticleBackendObject->ArticleUpdate(
        TicketID  => 123,
        ArticleID => 123,
        Key       => 'SenderType',
        Value     => 'agent',
        UserID    => 123,
    );

Events:
    ArticleUpdate

=cut

sub ArticleUpdate {
    my ( $Self, %Param ) = @_;

    for my $Item (qw(TicketID ArticleID UserID Key)) {
        if ( !$Param{$Item} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Item!",
            );
            return;
        }
    }

    if ( !defined $Param{Value} ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => 'Need Value!',
        );
        return;
    }

    my $ArticleObject = $Kernel::OM->Get('Kernel::System::Ticket::Article');

    # Lookup for sender type ID.
    if ( $Param{Key} eq 'SenderType' ) {
        $Param{Key}   = 'SenderTypeID';
        $Param{Value} = $ArticleObject->ArticleSenderTypeLookup(
            SenderType => $Param{Value},
        );
    }

    my %Map = (
        Body    => 'a_body',
        Subject => 'a_subject',
        From    => 'a_from',
        To      => 'a_to',
        Cc      => 'a_cc',
        Bcc     => 'a_bcc',
        ReplyTo => 'a_reply_to',
    );

    if ( $Map{ $Param{Key} } ) {
        return if !$Kernel::OM->Get('Kernel::System::DB')->Do(
            SQL => "
                UPDATE article_data_mime
                SET $Map{ $Param{Key} } = ?, change_time = current_timestamp, change_by = ?
                WHERE article_id = ?
            ",
            Bind => [ \$Param{Value}, \$Param{UserID}, \$Param{ArticleID} ],
        );
    }
    else {
        return if !$Self->_MetaArticleUpdate(
            %Param,
        );
    }

    $ArticleObject->_ArticleCacheClear(
        TicketID => $Param{TicketID},
    );

    $ArticleObject->ArticleSearchIndexBuild(
        TicketID  => $Param{TicketID},
        ArticleID => $Param{ArticleID},
        UserID    => $Param{UserID},
    );

    $Self->EventHandler(
        Event => 'ArticleUpdate',
        Data  => {
            TicketID  => $Param{TicketID},
            ArticleID => $Param{ArticleID},
        },
        UserID => $Param{UserID},
    );

    return 1;
}

=head2 ArticleDelete()

Delete article data, its plain message, and all attachments.

    my $Success = $ArticleBackendObject->ArticleDelete(
        TicketID  => 123,
        ArticleID => 123,
        UserID    => 123,
    );

=cut

sub ArticleDelete {    ## no critic;
    my ( $Self, %Param ) = @_;

    for my $Needed (qw(ArticleID TicketID UserID)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # Delete from article storage.
    return if !$Kernel::OM->Get( $Self->{ArticleStorageModule} )->ArticleDelete(%Param);

    my $DBObject = $Kernel::OM->Get('Kernel::System::DB');

    # Delete article data.
    return if !$DBObject->Do(
        SQL  => 'DELETE FROM article_data_mime WHERE article_id = ?',
        Bind => [ \$Param{ArticleID} ],
    );

    # Delete related transmission error entries.
    return if !$DBObject->Do(
        SQL  => 'DELETE FROM article_data_mime_send_error WHERE article_id = ?',
        Bind => [ \$Param{ArticleID} ],
    );

    # Delete meta article and associated data, and clear cache.
    return $Self->_MetaArticleDelete(
        %Param,
    );
}

=head1 STORAGE BACKEND DELEGATE METHODS

=head2 ArticleWritePlain()

Write a plain email to storage. This is a delegate method from active backend.

    my $Success = $ArticleBackendObject->ArticleWritePlain(
        ArticleID => 123,
        Email     => $EmailAsString,
        UserID    => 123,
    );

=cut

sub ArticleWritePlain {    ## no critic;
    my $Self = shift;
    return $Kernel::OM->Get( $Self->{ArticleStorageModule} )->ArticleWritePlain(@_);
}

=head2 ArticlePlain()

Get plain article/email from storage. This is a delegate method from active backend.

    my $PlainMessage = $ArticleBackendObject->ArticlePlain(
        ArticleID => 123,
        UserID    => 123,
    );

Returns:

    $PlainMessage = '
        From: OTRS Feedback <marketing@otrs.com>
        To: Your OTRS System <otrs@localhost>
        Subject: Welcome to OTRS!
        Content-Type: text/plain; charset=utf-8
        Content-Transfer-Encoding: 8bit

        Welcome to OTRS!
        # ...
    ';

=cut

sub ArticlePlain {    ## no critic;
    my $Self = shift;
    return $Kernel::OM->Get( $Self->{ArticleStorageModule} )->ArticlePlain(@_);
}

=head2 ArticleDeletePlain()

Delete a plain article from storage. This is a delegate method from active backend.

    my $Success = $ArticleBackendObject->ArticleDeletePlain(
        ArticleID => 123,
        UserID    => 123,
    );

=cut

sub ArticleDeletePlain {    ## no critic;
    my $Self = shift;
    return $Kernel::OM->Get( $Self->{ArticleStorageModule} )->ArticleDeletePlain(@_);
}

=head2 ArticleWriteAttachment()

Write an article attachment to storage. This is a delegate method from active backend.

    my $Success = $ArticleBackendObject->ArticleWriteAttachment(
        Content            => $ContentAsString,
        ContentType        => 'text/html; charset="iso-8859-15"',
        Filename           => 'lala.html',
        ContentID          => 'cid-1234',   # optional
        ContentAlternative => 0,            # optional, alternative content to shown as body
        Disposition        => 'attachment', # or 'inline'
        ArticleID          => 123,
        UserID             => 123,
    );

=cut

sub ArticleWriteAttachment {    ## no critic;
    my $Self = shift;
    return $Kernel::OM->Get( $Self->{ArticleStorageModule} )->ArticleWriteAttachment(@_);
}

=head2 ArticleAttachment()

Get article attachment from storage. This is a delegate method from active backend.

    my %Attachment = $ArticleBackendObject->ArticleAttachment(
        ArticleID => 123,
        FileID    => 1,   # as returned by ArticleAttachmentIndex
    );

Returns:

    %Attachment = (
        Content            => 'xxxx',     # actual attachment contents
        ContentAlternative => '',
        ContentID          => '',
        ContentType        => 'application/pdf',
        Filename           => 'StdAttachment-Test1.pdf',
        FilesizeRaw        => 4722,
        Disposition        => 'attachment',
    );

=cut

sub ArticleAttachment {    ## no critic;
    my $Self = shift;
    return $Kernel::OM->Get( $Self->{ArticleStorageModule} )->ArticleAttachment(@_);
}

=head2 ArticleDeleteAttachment()

Delete all attachments of an article from storage. This is a delegate method from active backend.

    my $Success = $ArticleBackendObject->ArticleDeleteAttachment(
        ArticleID => 123,
        UserID    => 123,
    );

=cut

sub ArticleDeleteAttachment {    ## no critic;
    my $Self = shift;
    return $Kernel::OM->Get( $Self->{ArticleStorageModule} )->ArticleDeleteAttachment(@_);
}

=head2 ArticleAttachmentIndex()

Get article attachment index as hash.

    my %Index = $ArticleBackendObject->ArticleAttachmentIndex(
        ArticleID        => 123,
        ExcludePlainText => 1,       # (optional) Exclude plain text attachment
        ExcludeHTMLBody  => 1,       # (optional) Exclude HTML body attachment
        ExcludeInline    => 1,       # (optional) Exclude inline attachments
    );

Returns:

    my %Index = {
        '1' => {                                                # Attachment ID
            ContentAlternative => '',                           # (optional)
            ContentID          => '',                           # (optional)
            Filesize           => '4.6 KB',
            ContentType        => 'application/pdf',
            FilesizeRaw        => 4722,
            Disposition        => 'attachment',
        },
        '2' => {
            ContentAlternative => '',
            ContentID          => '',
            Filesize           => '183 B',
            ContentType        => 'text/html; charset="utf-8"',
            FilesizeRaw        => 183,
            Disposition        => 'attachment',
        },
        # ...
    };

=cut

sub ArticleAttachmentIndex {    ## no critic
    my $Self = shift;
    return $Kernel::OM->Get( $Self->{ArticleStorageModule} )->ArticleAttachmentIndex(@_);
}

=head2 BackendSearchableFieldsGet()

Get the definition of the searchable fields as a hash.

    my %SearchableFields = $ArticleBackendObject->BackendSearchableFieldsGet();

Returns:

    my %SearchableFields = (
        'MIMEBase_From' => {
            Label      => 'From',
            Key        => 'MIMEBase_From',
            Type       => 'Text',
            Filterable => 0,
        },
        'MIMEBase_To' => {
            Label      => 'To',
            Key        => 'MIMEBase_To',
            Type       => 'Text',
            Filterable => 0,
        },
        'MIMEBase_Cc' => {
            Label      => 'Cc',
            Key        => 'MIMEBase_Cc',
            Type       => 'Text',
            Filterable => 0,
        },
        'MIMEBase_Bcc' => {
            Label      => 'Bcc',
            Key        => 'MIMEBase_Bcc',
            Type       => 'Text',
            Filterable => 0,
        },
        'MIMEBase_Subject' => {
            Label      => 'Subject',
            Key        => 'MIMEBase_Subject',
            Type       => 'Text',
            Filterable => 1,
        },
        'MIMEBase_Body' => {
            Label      => 'Body',
            Key        => 'MIMEBase_Body',
            Type       => 'Text',
            Filterable => 1,
        },
        'MIMEBase_AttachmentName' => {
            Label      => 'Attachment Name',
            Key        => 'MIMEBase_AttachmentName',
            Type       => 'Text',
            Filterable => 0,
        },
    );

=cut

sub BackendSearchableFieldsGet {
    my ( $Self, %Param ) = @_;

    my %SearchableFields = (
        'MIMEBase_From' => {
            Label      => 'From',
            Key        => 'MIMEBase_From',
            Type       => 'Text',
            Filterable => 0,
        },
        'MIMEBase_To' => {
            Label      => 'To',
            Key        => 'MIMEBase_To',
            Type       => 'Text',
            Filterable => 0,
        },
        'MIMEBase_Cc' => {
            Label      => 'Cc',
            Key        => 'MIMEBase_Cc',
            Type       => 'Text',
            Filterable => 0,
        },
        'MIMEBase_Bcc' => {
            Label                   => 'Bcc',
            Key                     => 'MIMEBase_Bcc',
            Type                    => 'Text',
            Filterable              => 0,
            HideInCustomerInterface => 1,
        },
        'MIMEBase_Subject' => {
            Label      => 'Subject',
            Key        => 'MIMEBase_Subject',
            Type       => 'Text',
            Filterable => 1,
        },
        'MIMEBase_Body' => {
            Label      => 'Body',
            Key        => 'MIMEBase_Body',
            Type       => 'Text',
            Filterable => 1,
        },
    );

    if ( $Kernel::OM->Get('Kernel::Config')->Get('Ticket::Article::Backend::MIMEBase::IndexAttachmentNames') ) {
        $SearchableFields{'MIMEBase_AttachmentName'} = {
            Label      => 'Attachment Name',
            Key        => 'MIMEBase_AttachmentName',
            Type       => 'Text',
            Filterable => 0,
        };
    }

    return %SearchableFields;
}

=head2 ArticleSearchableContentGet()

Get article attachment index as hash.

    my %Index = $ArticleBackendObject->ArticleSearchableContentGet(
        TicketID       => 123,   # (required)
        ArticleID      => 123,   # (required)
        DynamicFields  => 1,     # (optional) To include the dynamic field values for this article on the return structure.
        RealNames      => 1,     # (optional) To include the From/To/Cc/Bcc fields with real names.
        UserID         => 123,   # (required)
    );

Returns:

    my %ArticleSearchData = {
        'From'    => {
            String     => 'Test User1 <testuser1@example.com>',
            Key        => 'From',
            Type       => 'Text',
            Filterable => 0,
        },
        'To'    => {
            String     => 'Test User2 <testuser2@example.com>',
            Key        => 'To',
            Type       => 'Text',
            Filterable => 0,
        },
        'Cc'    => {
            String     => 'Test User3 <testuser3@example.com>',
            Key        => 'Cc',
            Type       => 'Text',
            Filterable => 0,
        },
        'Bcc'    => {
            String     => 'Test User4 <testuser4@example.com>',
            Key        => 'Bcc',
            Type       => 'Text',
            Filterable => 0,
        },
        'Subject'    => {
            String     => 'This is a test subject!',
            Key        => 'Subject',
            Type       => 'Text',
            Filterable => 1,
        },
        'Body'    => {
            String     => 'This is a body text!',
            Key        => 'Body',
            Type       => 'Text',
            Filterable => 1,
        }
    };

=cut

sub ArticleSearchableContentGet {
    my ( $Self, %Param ) = @_;

    for my $Item (qw(TicketID ArticleID UserID)) {
        if ( !$Param{$Item} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Item!"
            );
            return;
        }
    }

    my %DataKeyMap = (
        'MIMEBase_From'    => 'From',
        'MIMEBase_To'      => 'To',
        'MIMEBase_Cc'      => 'Cc',
        'MIMEBase_Bcc'     => 'Bcc',
        'MIMEBase_Subject' => 'Subject',
        'MIMEBase_Body'    => 'Body',
    );

    my %ArticleData = $Self->ArticleGet(
        TicketID      => $Param{TicketID},
        ArticleID     => $Param{ArticleID},
        UserID        => $Param{UserID},
        DynamicFields => 0,
    );

    my %BackendSearchableFields = $Self->BackendSearchableFieldsGet();

    my %ArticleSearchData;

    FIELDKEY:
    for my $FieldKey ( sort keys %BackendSearchableFields ) {

        my $IndexString;

        # scan available attachment names and append the information
        if ( $FieldKey eq 'MIMEBase_AttachmentName' ) {

            my %AttachmentIndex = $Self->ArticleAttachmentIndex(
                ArticleID        => $Param{ArticleID},
                UserID           => $Param{UserID},
                ExcludePlainText => 1,
                ExcludeHTMLBody  => 1,
                ExcludeInline    => 1,
            );

            next FIELDKEY if !%AttachmentIndex;

            my @AttachmentNames;

            for my $AttachmentKey ( sort keys %AttachmentIndex ) {
                push @AttachmentNames, $AttachmentIndex{$AttachmentKey}->{Filename};
            }

            $IndexString = join ' ', @AttachmentNames;
        }

        $IndexString //= $ArticleData{ $DataKeyMap{$FieldKey} };

        next FIELDKEY if !IsStringWithData($IndexString);

        $ArticleSearchData{$FieldKey} = {
            String     => $IndexString,
            Key        => $BackendSearchableFields{$FieldKey}->{Key},
            Type       => $BackendSearchableFields{$FieldKey}->{Type}       // 'Text',
            Filterable => $BackendSearchableFields{$FieldKey}->{Filterable} // 0,
        };
    }

    return %ArticleSearchData;
}

=head2 ArticleHasHTMLContent()

Returns 1 if article has HTML content.

    my $ArticleHasHTMLContent = $ArticleBackendObject->ArticleHasHTMLContent(
        TicketID  => 1,
        ArticleID => 2,
        UserID    => 1,
    );

Result:

    $ArticleHasHTMLContent = 1;     # or 0

=cut

sub ArticleHasHTMLContent {
    my ( $Self, %Param ) = @_;

    # Check needed stuff.
    for my $Needed (qw(TicketID ArticleID UserID)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # Check if there is HTML body attachment.
    my %AttachmentIndexHTMLBody = $Self->ArticleAttachmentIndex(
        %Param,
        OnlyHTMLBody => 1,
    );

    my ($HTMLBodyAttachmentID) = sort keys %AttachmentIndexHTMLBody;

    return $HTMLBodyAttachmentID ? 1 : 0;
}

1;

=head1 TERMS AND CONDITIONS

This software is part of the OTRS project (L<https://otrs.org/>).

This software comes with ABSOLUTELY NO WARRANTY. For details, see
the enclosed file COPYING for license information (GPL). If you
did not receive this file, see L<https://www.gnu.org/licenses/gpl-3.0.txt>.

=cut
