Загрузка данных


#requires -Version 5.1

<#
.SYNOPSIS
    Отправляет email при получении Event ID 4741 в журнал ForwardedEvents на WEC.

.DESCRIPTION
    Совместимо с Windows PowerShell 5.1 / .NET Framework.

    - Читает локальный ForwardedEvents на WEC.
    - Обрабатывает только Event ID 4741.
    - После успешной отправки письма сохраняет EventBookmark.
    - Если SMTP недоступен, bookmark не сдвигается и событие повторится на следующем запуске.
    - Если checkpoint сохранить не удалось, скрипт прекращает обработку, чтобы не перескочить событие.
    - Ведет подробный Transcript.
    - Не допускает одновременный запуск двух экземпляров.
#>

Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'

# ============================================================
# CONFIGURATION
# ============================================================

$SmtpServer    = 'mail.remind.ru'
$SmtpPort      = 25
$SmtpEnableSsl = $false
$SmtpTimeoutMs = 15000

$MailFrom = 'ad-computer-report@remind.ru'
$MailTo = @(
    'Pavel.semenov@remind.ru',
    'Vladimir.zatulov@remind.ru',
    'Stanislav.nikonov@remind.ru',
    'imarchenko@remind.ru'
)

$EventLogName = 'ForwardedEvents'
$EventXPath   = '*[System[(EventID=4741)]]'

# false = при первом запуске существующие 4741 не отправлять,
#         а взять последнее существующее событие как baseline.
# true  = при первом запуске обработать всю имеющуюся историю 4741.
$ProcessExistingEventsOnFirstRun = $false

$BaseDirectory = 'C:\ProgramData\AD-Computer-Report'
$LogDirectory  = Join-Path $BaseDirectory 'Logs'
$StateFile     = Join-Path $BaseDirectory 'State.json'
$StateBackupFile = Join-Path $BaseDirectory 'State.json.bak'
$LockFile      = Join-Path $BaseDirectory 'run.lock'

$LogRetentionDays = 30
$StateVersion = 2

# ============================================================
# INITIAL DIRECTORIES
# ============================================================

foreach ($Directory in @($BaseDirectory, $LogDirectory)) {
    if (-not (Test-Path -LiteralPath $Directory)) {
        New-Item -Path $Directory -ItemType Directory -Force | Out-Null
    }
}

# ============================================================
# SINGLE INSTANCE LOCK
# ============================================================

$LockStream = $null

try {
    $LockStream = [System.IO.File]::Open(
        $LockFile,
        [System.IO.FileMode]::OpenOrCreate,
        [System.IO.FileAccess]::ReadWrite,
        [System.IO.FileShare]::None
    )

    $LockText = "PID=$PID`r`nStarted=$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')`r`nComputer=$env:COMPUTERNAME`r`n"
    $LockBytes = [System.Text.Encoding]::UTF8.GetBytes($LockText)

    $LockStream.SetLength(0)
    $LockStream.Write($LockBytes, 0, $LockBytes.Length)
    $LockStream.Flush()
}
catch [System.IO.IOException] {
    Write-Host 'Another instance of AD-Computer-Report is already running.'
    exit 0
}

# ============================================================
# TRANSCRIPT
# ============================================================

$TranscriptFile = Join-Path $LogDirectory ("AD-Computer-Report_{0:yyyy-MM-dd_HH-mm-ss}.log" -f (Get-Date))
$TranscriptStarted = $false
$ExitCode = 0

# ============================================================
# FUNCTIONS
# ============================================================

function Write-Log {
    param (
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Message,

        [ValidateSet('INFO', 'SUCCESS', 'WARNING', 'ERROR', 'DEBUG')]
        [string]$Level = 'INFO'
    )

    if ([string]::IsNullOrEmpty($Message)) {
        Write-Host ''
        return
    }

    $Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'
    Write-Host "[$Timestamp][$Level] $Message"
}

function Write-ExceptionLog {
    param (
        [Parameter(Mandatory = $true)]
        [string]$Context,

        [Parameter(Mandatory = $true)]
        $ErrorRecord
    )

    Write-Log -Level 'ERROR' -Message $Context
    Write-Log -Level 'ERROR' -Message "Exception: $($ErrorRecord.Exception.GetType().FullName)"
    Write-Log -Level 'ERROR' -Message "Message: $($ErrorRecord.Exception.Message)"

    if ($ErrorRecord.FullyQualifiedErrorId) {
        Write-Log -Level 'DEBUG' -Message "FullyQualifiedErrorId: $($ErrorRecord.FullyQualifiedErrorId)"
    }

    if ($ErrorRecord.ScriptStackTrace) {
        Write-Log -Level 'DEBUG' -Message "StackTrace: $($ErrorRecord.ScriptStackTrace)"
    }
}

function Convert-ToHtml {
    param (
        [AllowNull()]
        [object]$Value
    )

    if ($null -eq $Value) {
        return ''
    }

    return [System.Net.WebUtility]::HtmlEncode([string]$Value)
}

function Get-StatePropertyValue {
    param (
        [AllowNull()]
        $State,

        [Parameter(Mandatory = $true)]
        [string]$Name
    )

    if ($null -eq $State) {
        return $null
    }

    $Property = $State.PSObject.Properties[$Name]

    if ($null -eq $Property) {
        return $null
    }

    return $Property.Value
}

function New-ForwardedEventQuery {
    param (
        [bool]$Reverse = $false
    )

    $Query = New-Object -TypeName 'System.Diagnostics.Eventing.Reader.EventLogQuery' -ArgumentList @(
        $EventLogName,
        [System.Diagnostics.Eventing.Reader.PathType]::LogName,
        $EventXPath
    )

    $Query.ReverseDirection = $Reverse
    return $Query
}

function New-EventLogReader {
    param (
        [Parameter(Mandatory = $true)]
        [System.Diagnostics.Eventing.Reader.EventLogQuery]$Query,

        [AllowNull()]
        [System.Diagnostics.Eventing.Reader.EventBookmark]$Bookmark
    )

    if ($null -eq $Bookmark) {
        return (New-Object -TypeName 'System.Diagnostics.Eventing.Reader.EventLogReader' -ArgumentList @($Query))
    }

    return (New-Object -TypeName 'System.Diagnostics.Eventing.Reader.EventLogReader' -ArgumentList @($Query, $Bookmark))
}

function Get-EventBookmarkString {
    param (
        [Parameter(Mandatory = $true)]
        [System.Diagnostics.Eventing.Reader.EventBookmark]$Bookmark
    )

    # В новых реализациях .NET BookmarkXml может быть публичным.
    $PublicProperty = $Bookmark.PSObject.Properties['BookmarkXml']

    if ($null -ne $PublicProperty) {
        $Value = [string]$PublicProperty.Value
        if (-not [string]::IsNullOrWhiteSpace($Value)) {
            return $Value
        }
    }

    # Windows PowerShell 5.1 / .NET Framework использует внутренний BookmarkText.
    $Flags = [System.Reflection.BindingFlags]::Instance -bor `
             [System.Reflection.BindingFlags]::Public -bor `
             [System.Reflection.BindingFlags]::NonPublic

    $BookmarkType = $Bookmark.GetType()

    $InternalProperty = $BookmarkType.GetProperty('BookmarkText', $Flags)
    if ($null -ne $InternalProperty) {
        $Value = [string]$InternalProperty.GetValue($Bookmark, $null)
        if (-not [string]::IsNullOrWhiteSpace($Value)) {
            return $Value
        }
    }

    # Дополнительный fallback на случай отличия реализации .NET.
    foreach ($FieldName in @('bookmarkText', '_bookmarkText')) {
        $InternalField = $BookmarkType.GetField($FieldName, $Flags)
        if ($null -ne $InternalField) {
            $Value = [string]$InternalField.GetValue($Bookmark)
            if (-not [string]::IsNullOrWhiteSpace($Value)) {
                return $Value
            }
        }
    }

    throw 'Unable to extract bookmark XML/text from EventBookmark.'
}

function New-EventBookmarkFromString {
    param (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$BookmarkString
    )

    # В .NET Framework конструктор EventBookmark(String) существует.
    # Используем reflection, чтобы одинаково работать в разных реализациях.
    $BookmarkType = [System.Diagnostics.Eventing.Reader.EventBookmark]

    $Flags = [System.Reflection.BindingFlags]::Instance -bor `
             [System.Reflection.BindingFlags]::Public -bor `
             [System.Reflection.BindingFlags]::NonPublic

    $Constructor = $BookmarkType.GetConstructor(
        $Flags,
        $null,
        [Type[]]@([string]),
        $null
    )

    if ($null -eq $Constructor) {
        throw 'EventBookmark(String) constructor was not found.'
    }

    $Bookmark = $Constructor.Invoke([object[]]@($BookmarkString))

    if ($null -eq $Bookmark) {
        throw 'EventBookmark could not be reconstructed from stored state.'
    }

    return $Bookmark
}

function Get-EventInformation {
    param (
        [Parameter(Mandatory = $true)]
        [System.Diagnostics.Eventing.Reader.EventRecord]$Event
    )

    [xml]$Xml = $Event.ToXml()
    $EventData = @{}

    foreach ($Item in @($Xml.Event.EventData.Data)) {
        if ($null -eq $Item) {
            continue
        }

        $Name = [string]$Item.Name

        if (-not [string]::IsNullOrWhiteSpace($Name)) {
            $EventData[$Name] = [string]$Item.'#text'
        }
    }

    $ComputerName = [string]$EventData['TargetUserName']
    if ([string]::IsNullOrWhiteSpace($ComputerName)) {
        $ComputerName = 'UNKNOWN'
    }

    $CreatedByDomain = [string]$EventData['SubjectDomainName']
    $CreatedByUser   = [string]$EventData['SubjectUserName']

    if (-not [string]::IsNullOrWhiteSpace($CreatedByDomain) -and
        -not [string]::IsNullOrWhiteSpace($CreatedByUser)) {
        $CreatedBy = "$CreatedByDomain\$CreatedByUser"
    }
    elseif (-not [string]::IsNullOrWhiteSpace($CreatedByUser)) {
        $CreatedBy = $CreatedByUser
    }
    else {
        $CreatedBy = ''
    }

    [PSCustomObject]@{
        ComputerName         = $ComputerName
        DNSHostName          = [string]$EventData['DnsHostName']
        TargetDomain         = [string]$EventData['TargetDomainName']
        TargetSID            = [string]$EventData['TargetSid']
        CreatedBy            = $CreatedBy
        CreatedBySID         = [string]$EventData['SubjectUserSid']
        SourceComputer       = [string]$Xml.Event.System.Computer
        SourceRecordId       = [string]$Xml.Event.System.EventRecordID
        EventTime            = $Event.TimeCreated
        SamAccountName       = [string]$EventData['SamAccountName']
        ServicePrincipalNames = [string]$EventData['ServicePrincipalNames']
    }
}

function Save-State {
    param (
        [AllowNull()]
        [string]$BookmarkXml,

        [AllowNull()]
        $EventInfo
    )

    $LastSourceComputer = $null
    $LastSourceRecordId = $null
    $LastComputerName   = $null
    $LastEventTime      = $null

    if ($null -ne $EventInfo) {
        $LastSourceComputer = $EventInfo.SourceComputer
        $LastSourceRecordId = $EventInfo.SourceRecordId
        $LastComputerName   = $EventInfo.ComputerName

        if ($null -ne $EventInfo.EventTime) {
            $LastEventTime = $EventInfo.EventTime.ToString('o')
        }
    }

    $State = [ordered]@{
        Version            = $StateVersion
        Initialized        = $true
        BookmarkXml        = $BookmarkXml
        UpdatedAt          = (Get-Date).ToString('o')
        LastSourceComputer = $LastSourceComputer
        LastSourceRecordId = $LastSourceRecordId
        LastComputerName   = $LastComputerName
        LastEventTime      = $LastEventTime
    }

    $Json = $State | ConvertTo-Json -Depth 5
    $TempFile = "$StateFile.tmp"
    $Utf8NoBom = New-Object -TypeName 'System.Text.UTF8Encoding' -ArgumentList @($false)

    try {
        [System.IO.File]::WriteAllText($TempFile, $Json, $Utf8NoBom)

        if (Test-Path -LiteralPath $StateFile) {
            if (Test-Path -LiteralPath $StateBackupFile) {
                Remove-Item -LiteralPath $StateBackupFile -Force -ErrorAction SilentlyContinue
            }

            [System.IO.File]::Replace($TempFile, $StateFile, $StateBackupFile, $true)
        }
        else {
            [System.IO.File]::Move($TempFile, $StateFile)
        }

        Write-Log -Level 'DEBUG' -Message "State successfully saved: $StateFile"
    }
    catch {
        if (Test-Path -LiteralPath $TempFile) {
            Remove-Item -LiteralPath $TempFile -Force -ErrorAction SilentlyContinue
        }
        throw
    }
}

function Load-State {
    if (-not (Test-Path -LiteralPath $StateFile)) {
        Write-Log -Message 'State file does not exist. This is the first run.'
        return $null
    }

    try {
        $RawState = Get-Content -LiteralPath $StateFile -Raw -ErrorAction Stop
        $State = $RawState | ConvertFrom-Json -ErrorAction Stop
        Write-Log -Level 'SUCCESS' -Message "State successfully loaded: $StateFile"
        return $State
    }
    catch {
        Write-ExceptionLog -Context 'Primary state file cannot be read.' -ErrorRecord $_

        if (Test-Path -LiteralPath $StateBackupFile) {
            Write-Log -Level 'WARNING' -Message "Attempting to load backup state: $StateBackupFile"

            try {
                $RawState = Get-Content -LiteralPath $StateBackupFile -Raw -ErrorAction Stop
                $State = $RawState | ConvertFrom-Json -ErrorAction Stop
                Write-Log -Level 'SUCCESS' -Message 'Backup state successfully loaded.'
                return $State
            }
            catch {
                Write-ExceptionLog -Context 'Backup state cannot be read.' -ErrorRecord $_
            }
        }

        throw 'No valid state file is available.'
    }
}

function Send-NewComputerMail {
    param (
        [Parameter(Mandatory = $true)]
        $EventInfo
    )

    $DisplayComputerName = ([string]$EventInfo.ComputerName).TrimEnd('$')
    if ([string]::IsNullOrWhiteSpace($DisplayComputerName)) {
        $DisplayComputerName = 'UNKNOWN'
    }

    $Subject = "AD: новый компьютер $DisplayComputerName"

    $EventTimeText = ''
    if ($null -ne $EventInfo.EventTime) {
        $EventTimeText = $EventInfo.EventTime.ToString('dd.MM.yyyy HH:mm:ss')
    }

    $ComputerHtml     = Convert-ToHtml $DisplayComputerName
    $SamHtml          = Convert-ToHtml $EventInfo.ComputerName
    $DnsHtml          = Convert-ToHtml $EventInfo.DNSHostName
    $DomainHtml       = Convert-ToHtml $EventInfo.TargetDomain
    $SidHtml          = Convert-ToHtml $EventInfo.TargetSID
    $CreatedByHtml    = Convert-ToHtml $EventInfo.CreatedBy
    $CreatedBySidHtml = Convert-ToHtml $EventInfo.CreatedBySID
    $SourceHtml       = Convert-ToHtml $EventInfo.SourceComputer
    $RecordHtml       = Convert-ToHtml $EventInfo.SourceRecordId
    $TimeHtml         = Convert-ToHtml $EventTimeText
    $WecHtml          = Convert-ToHtml $env:COMPUTERNAME

    $Body = @"
<html>
<head>
<meta charset="utf-8">
</head>
<body style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;color:#222222;">
<h2>В Active Directory создан новый компьютер</h2>
<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;">
<tr><td><b>Компьютер:</b></td><td>$ComputerHtml</td></tr>
<tr><td><b>SAM Account:</b></td><td>$SamHtml</td></tr>
<tr><td><b>DNS:</b></td><td>$DnsHtml</td></tr>
<tr><td><b>Домен:</b></td><td>$DomainHtml</td></tr>
<tr><td><b>Computer SID:</b></td><td>$SidHtml</td></tr>
<tr><td><b>Создал:</b></td><td>$CreatedByHtml</td></tr>
<tr><td><b>SID инициатора:</b></td><td>$CreatedBySidHtml</td></tr>
<tr><td><b>Контроллер домена:</b></td><td>$SourceHtml</td></tr>
<tr><td><b>Время события:</b></td><td>$TimeHtml</td></tr>
<tr><td><b>Event ID:</b></td><td>4741</td></tr>
<tr><td><b>EventRecordID на DC:</b></td><td>$RecordHtml</td></tr>
<tr><td><b>WEC:</b></td><td>$WecHtml</td></tr>
</table>
<br>
<div style="color:#666666;font-size:12px;">Автоматическое уведомление системы мониторинга Active Directory.</div>
</body>
</html>
"@

    $MailMessage = New-Object -TypeName 'System.Net.Mail.MailMessage'
    $Smtp = $null

    try {
        $MailMessage.From = New-Object -TypeName 'System.Net.Mail.MailAddress' -ArgumentList @($MailFrom)

        foreach ($Recipient in $MailTo) {
            [void]$MailMessage.To.Add((New-Object -TypeName 'System.Net.Mail.MailAddress' -ArgumentList @($Recipient)))
        }

        $MailMessage.Subject = $Subject
        $MailMessage.Body = $Body
        $MailMessage.IsBodyHtml = $true
        $MailMessage.SubjectEncoding = [System.Text.Encoding]::UTF8
        $MailMessage.BodyEncoding = [System.Text.Encoding]::UTF8

        $Smtp = New-Object -TypeName 'System.Net.Mail.SmtpClient' -ArgumentList @($SmtpServer, $SmtpPort)
        $Smtp.EnableSsl = $SmtpEnableSsl
        $Smtp.UseDefaultCredentials = $false
        $Smtp.DeliveryMethod = [System.Net.Mail.SmtpDeliveryMethod]::Network
        $Smtp.Timeout = $SmtpTimeoutMs

        Write-Log -Message "SMTP: sending '$Subject' via $SmtpServer`:$SmtpPort"
        $Smtp.Send($MailMessage)
        Write-Log -Level 'SUCCESS' -Message "Email successfully sent for computer '$DisplayComputerName'."
    }
    finally {
        if ($null -ne $Smtp) {
            $Smtp.Dispose()
        }

        if ($null -ne $MailMessage) {
            $MailMessage.Dispose()
        }
    }
}

function Initialize-BookmarkFromCurrentEnd {
    Write-Log -Message 'Looking for the newest existing Event ID 4741 to create baseline...'

    $Query = New-ForwardedEventQuery -Reverse $true
    $Reader = $null
    $LastEvent = $null

    try {
        $Reader = New-EventLogReader -Query $Query -Bookmark $null
        $LastEvent = $Reader.ReadEvent()

        if ($null -ne $LastEvent) {
            $Info = Get-EventInformation -Event $LastEvent
            $BookmarkString = Get-EventBookmarkString -Bookmark $LastEvent.Bookmark

            # Проверяем round-trip ДО сохранения state.
            $TestBookmark = New-EventBookmarkFromString -BookmarkString $BookmarkString
            if ($null -eq $TestBookmark) {
                throw 'Bookmark round-trip validation failed.'
            }

            Write-Log -Level 'WARNING' -Message "Baseline event: Computer='$($Info.ComputerName)', DC='$($Info.SourceComputer)', RecordID='$($Info.SourceRecordId)'."

            Save-State -BookmarkXml $BookmarkString -EventInfo $Info
            Write-Log -Level 'SUCCESS' -Message 'Initial bookmark created. Existing events will NOT be emailed.'
        }
        else {
            Save-State -BookmarkXml $null -EventInfo $null
            Write-Log -Level 'SUCCESS' -Message 'No Event ID 4741 currently exists. Empty baseline state created.'
        }
    }
    finally {
        if ($null -ne $LastEvent) {
            $LastEvent.Dispose()
        }

        if ($null -ne $Reader) {
            $Reader.Dispose()
        }
    }
}

function Test-BookmarkSerialization {
    Write-Log -Message 'Testing EventBookmark serialization compatibility...'

    $Query = New-ForwardedEventQuery -Reverse $true
    $Reader = $null
    $Event = $null

    try {
        $Reader = New-EventLogReader -Query $Query -Bookmark $null
        $Event = $Reader.ReadEvent()

        if ($null -eq $Event) {
            Write-Log -Level 'WARNING' -Message 'No 4741 event exists yet; bookmark self-test cannot be performed now.'
            return
        }

        $BookmarkString = Get-EventBookmarkString -Bookmark $Event.Bookmark
        $RestoredBookmark = New-EventBookmarkFromString -BookmarkString $BookmarkString

        if ($null -eq $RestoredBookmark) {
            throw 'Restored bookmark is null.'
        }

        Write-Log -Level 'SUCCESS' -Message 'EventBookmark serialization self-test passed.'
    }
    finally {
        if ($null -ne $Event) {
            $Event.Dispose()
        }

        if ($null -ne $Reader) {
            $Reader.Dispose()
        }
    }
}

# ============================================================
# MAIN
# ============================================================

$RunStarted = Get-Date
$EventsProcessed = 0
$EmailErrors = 0
$StateErrors = 0

try {
    Start-Transcript -Path $TranscriptFile -Force -ErrorAction Stop
    $TranscriptStarted = $true

    Write-Log -Message '============================================================'
    Write-Log -Message 'AD Computer Report started'
    Write-Log -Message '============================================================'
    Write-Log -Message "WEC server : $env:COMPUTERNAME"
    Write-Log -Message "User       : $env:USERDOMAIN\$env:USERNAME"
    Write-Log -Message "PowerShell : $($PSVersionTable.PSVersion)"
    Write-Log -Message "Event log  : $EventLogName"
    Write-Log -Message 'Event ID   : 4741'
    Write-Log -Message "SMTP       : $SmtpServer`:$SmtpPort"
    Write-Log -Message "From       : $MailFrom"
    Write-Log -Message "Recipients : $($MailTo -join ', ')"
    Write-Log -Message "Transcript : $TranscriptFile"

    # --------------------------------------------------------
    # Verify ForwardedEvents
    # --------------------------------------------------------

    Write-Log -Message 'Checking ForwardedEvents log...'
    $LogInfo = Get-WinEvent -ListLog $EventLogName -ErrorAction Stop
    Write-Log -Level 'SUCCESS' -Message "ForwardedEvents available. RecordCount=$($LogInfo.RecordCount)"

    # Проверяем bookmark-механику до того, как отправлять письма.
    Test-BookmarkSerialization

    # --------------------------------------------------------
    # Load state / migrate old state
    # --------------------------------------------------------

    $State = Load-State
    $StateVersionFromFile = Get-StatePropertyValue -State $State -Name 'Version'

    if ($null -ne $State -and ([int]$StateVersionFromFile -lt $StateVersion)) {
        Write-Log -Level 'WARNING' -Message "Old/broken state format detected (Version=$StateVersionFromFile)."
        Write-Log -Level 'WARNING' -Message 'Creating a new baseline at the newest current 4741 to prevent duplicate mail from the previous script version.'

        Initialize-BookmarkFromCurrentEnd
        $State = Load-State
    }

    # --------------------------------------------------------
    # First run
    # --------------------------------------------------------

    if ($null -eq $State -and -not $ProcessExistingEventsOnFirstRun) {
        Write-Log -Level 'WARNING' -Message 'First run detected.'
        Initialize-BookmarkFromCurrentEnd
        Write-Log -Message 'Initialization completed. No existing events were emailed.'
    }
    else {
        # ----------------------------------------------------
        # Build query and restore bookmark
        # ----------------------------------------------------

        $Query = New-ForwardedEventQuery -Reverse $false
        $Bookmark = $null
        $StoredBookmarkXml = Get-StatePropertyValue -State $State -Name 'BookmarkXml'

        if (-not [string]::IsNullOrWhiteSpace([string]$StoredBookmarkXml)) {
            try {
                $Bookmark = New-EventBookmarkFromString -BookmarkString ([string]$StoredBookmarkXml)
                Write-Log -Level 'SUCCESS' -Message 'Saved EventBookmark successfully restored.'
            }
            catch {
                Write-ExceptionLog -Context 'Stored bookmark cannot be restored. ForwardedEvents may have been cleared/recreated or state is invalid.' -ErrorRecord $_
                Write-Log -Level 'WARNING' -Message 'Creating a new baseline at the newest current 4741. Existing events will not be re-sent.'

                Initialize-BookmarkFromCurrentEnd
                $Bookmark = $null
                $State = Load-State
                $StoredBookmarkXml = Get-StatePropertyValue -State $State -Name 'BookmarkXml'

                if (-not [string]::IsNullOrWhiteSpace([string]$StoredBookmarkXml)) {
                    $Bookmark = New-EventBookmarkFromString -BookmarkString ([string]$StoredBookmarkXml)
                }
            }
        }
        else {
            Write-Log -Message 'No saved bookmark. Reading matching events from the current ForwardedEvents log.'
        }

        # ----------------------------------------------------
        # Open reader and process events
        # ----------------------------------------------------

        $Reader = $null

        try {
            try {
                $Reader = New-EventLogReader -Query $Query -Bookmark $Bookmark
            }
            catch {
                if ($null -eq $Bookmark) {
                    throw
                }

                Write-ExceptionLog -Context 'EventLogReader cannot open ForwardedEvents from the saved bookmark.' -ErrorRecord $_
                Write-Log -Level 'WARNING' -Message 'The log may have been cleared/recreated. Creating a new baseline at the newest current 4741.'

                Initialize-BookmarkFromCurrentEnd
                $State = Load-State
                $StoredBookmarkXml = Get-StatePropertyValue -State $State -Name 'BookmarkXml'
                $Bookmark = $null

                if (-not [string]::IsNullOrWhiteSpace([string]$StoredBookmarkXml)) {
                    $Bookmark = New-EventBookmarkFromString -BookmarkString ([string]$StoredBookmarkXml)
                }

                $Query = New-ForwardedEventQuery -Reverse $false
                $Reader = New-EventLogReader -Query $Query -Bookmark $Bookmark
            }

            while ($true) {
                $Event = $Reader.ReadEvent()

                if ($null -eq $Event) {
                    break
                }

                try {
                    $Info = Get-EventInformation -Event $Event

                    Write-Log -Message ''
                    Write-Log -Message '------------------------------------------------------------'
                    Write-Log -Message 'Processing Event ID 4741'
                    Write-Log -Message "Computer       : $($Info.ComputerName)"
                    Write-Log -Message "DNS            : $($Info.DNSHostName)"
                    Write-Log -Message "Created by     : $($Info.CreatedBy)"
                    Write-Log -Message "Source DC      : $($Info.SourceComputer)"
                    Write-Log -Message "Source RecordID: $($Info.SourceRecordId)"
                    Write-Log -Message "Event time     : $($Info.EventTime)"

                    # ВАЖНО: сначала убеждаемся, что bookmark этого события можно сериализовать.
                    # Это не позволяет повторить предыдущую проблему: письмо ушло, а checkpoint сохранить невозможно.
                    try {
                        $BookmarkString = Get-EventBookmarkString -Bookmark $Event.Bookmark
                        $BookmarkRoundTrip = New-EventBookmarkFromString -BookmarkString $BookmarkString

                        if ($null -eq $BookmarkRoundTrip) {
                            throw 'Bookmark validation returned null.'
                        }
                    }
                    catch {
                        $StateErrors++
                        $ExitCode = 3
                        Write-ExceptionLog -Context "Bookmark validation FAILED for '$($Info.ComputerName)'. Email will NOT be sent." -ErrorRecord $_
                        Write-Log -Level 'WARNING' -Message 'Processing stops so no event can be skipped.'
                        break
                    }

                    # SMTP
                    try {
                        Send-NewComputerMail -EventInfo $Info
                    }
                    catch {
                        $EmailErrors++
                        $ExitCode = 2
                        Write-ExceptionLog -Context "EMAIL SEND FAILED for '$($Info.ComputerName)'." -ErrorRecord $_
                        Write-Log -Level 'WARNING' -Message 'Bookmark will NOT be advanced. The event will be retried on the next run.'
                        break
                    }

                    # Checkpoint only after successful SMTP.
                    try {
                        Save-State -BookmarkXml $BookmarkString -EventInfo $Info
                        $EventsProcessed++
                        Write-Log -Level 'SUCCESS' -Message 'Event successfully processed and checkpoint saved.'
                    }
                    catch {
                        $StateErrors++
                        $ExitCode = 3
                        Write-ExceptionLog -Context 'Email was sent, but state could NOT be saved.' -ErrorRecord $_
                        Write-Log -Level 'WARNING' -Message 'This event may be emailed again on the next run.'
                        Write-Log -Level 'WARNING' -Message 'Processing stops so later events cannot be skipped.'
                        break
                    }
                }
                finally {
                    if ($null -ne $Event) {
                        $Event.Dispose()
                    }
                }
            }
        }
        finally {
            if ($null -ne $Reader) {
                $Reader.Dispose()
            }
        }
    }

    # --------------------------------------------------------
    # Cleanup old transcripts
    # --------------------------------------------------------

    try {
        $CleanupBefore = (Get-Date).AddDays(-$LogRetentionDays)
        $OldLogs = @(
            Get-ChildItem -LiteralPath $LogDirectory -Filter '*.log' -File -ErrorAction Stop |
                Where-Object { $_.LastWriteTime -lt $CleanupBefore }
        )

        foreach ($OldLog in $OldLogs) {
            Write-Log -Level 'DEBUG' -Message "Removing old transcript: $($OldLog.Name)"
            Remove-Item -LiteralPath $OldLog.FullName -Force -ErrorAction Stop
        }
    }
    catch {
        Write-ExceptionLog -Context 'Transcript cleanup failed.' -ErrorRecord $_
    }

    # --------------------------------------------------------
    # Summary
    # --------------------------------------------------------

    $Duration = (Get-Date) - $RunStarted

    Write-Log -Message ''
    Write-Log -Message '============================================================'
    Write-Log -Message 'RUN SUMMARY'
    Write-Log -Message '============================================================'
    Write-Log -Message "Events processed : $EventsProcessed"
    Write-Log -Message "Email errors     : $EmailErrors"
    Write-Log -Message "State errors     : $StateErrors"
    Write-Log -Message "Exit code        : $ExitCode"
    Write-Log -Message "Duration         : $($Duration.ToString())"
    Write-Log -Message '============================================================'
}
catch {
    $ExitCode = 1
    Write-ExceptionLog -Context 'FATAL SCRIPT ERROR' -ErrorRecord $_
}
finally {
    if ($TranscriptStarted) {
        Write-Log -Message 'AD Computer Report finished.'
        Stop-Transcript | Out-Null
    }

    if ($null -ne $LockStream) {
        $LockStream.Dispose()
    }
}

exit $ExitCode