MtaStsResults.psm1

#Requires -Version 7.0

<#
.SYNOPSIS
    MTA-STS/DMARC Report Downloader and Parser Module
     
.DESCRIPTION
    This module provides functionality to download MTA-STS (Mail Transfer Agent Strict Transport Security)
    and DMARC (Domain-based Message Authentication, Reporting and Conformance) JSON reports from Microsoft
    Exchange mailboxes using the Microsoft Graph API, parse the JSON content, extract policy compliance
    statistics, and optionally clean up downloaded files.
     
.NOTES
    - Requires PowerShell 7.0 or later
    - Requires Microsoft.Graph module
    - Requires proper Azure AD app registration with certificate-based authentication
#>


# Module-scoped variables to store parsed data across function calls
$script:parsedJsonContent = @()
$script:msgFolder = $null

<#
.SYNOPSIS
    Downloads MTA-STS/DMARC JSON attachments from specified mailboxes.
     
.DESCRIPTION
    Connects to Microsoft Exchange mailboxes and downloads DMARC/MTA-STS JSON report attachments
    (.gz and .tgz files) that were received within a specified lookback period. The function filters
    for messages with attachments received since a specified date and saves them to a local directory
    structure organized by mailbox and date.
     
    The function intelligently handles attachment retrieval, preferring inline attachment content
    when available (returned via $expand=attachments) and falling back to explicit attachment
    retrieval API calls when necessary. Content is decoded from Base64 and written to disk.
     
.PARAMETER Mailbox
    Specifies one or more mailbox email addresses to scan. Example: "dmarc-reports@domain.com"
     
.PARAMETER OutRoot
    Root output directory where downloaded files will be saved. Subdirectories are created per
    mailbox and date. Default is ".\dmarc-attachments" in the current working directory.
     
.PARAMETER DaysLookBack
    Number of days to look back when filtering messages. Default is 1. For example, DaysLookBack
    of 7 will retrieve all qualifying messages from the past 7 days.
     
.EXAMPLE
    Invoke-DmarcAttachmentDownloader -Mailbox "dmarc@contoso.com" -OutRoot "C:\Reports" -DaysLookBack 7
     
    Downloads all MTA-STS/DMARC attachments from dmarc@contoso.com received in the last 7 days
    and saves them under C:\Reports\dmarc@contoso.com\DDMMYYYY\ directories.
     
.NOTES
    - Requires active Microsoft Graph connection (Connect-MgGraph must be called first)
    - Files are organized under $OutRoot\$Mailbox\$DateFolder\
    - Skips non-.gz/.tgz attachments with a warning
    - Sanitizes filenames to be compatible with Windows filesystem
#>

function Invoke-DmarcAttachmentDownloader {
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string[]]$Mailbox,
        
        [Parameter(Mandatory = $false)]
        [string]$OutRoot = ".\dmarc-attachments",
        
        [Parameter(Mandatory = $false)]
        [int]$DaysLookBack = 1
    )

    foreach ($mbx in $Mailbox) {
        Write-Verbose "Processing mailbox: $mbx"
        
        $outDir = Join-Path $OutRoot $mbx
        New-Item -Path $outDir -ItemType Directory -Force | Out-Null
        Write-Verbose "Created output directory: $outDir"

        # Calculate the UTC timestamp for the lookback period
        $sinceUtc = (Get-Date).AddDays(-[int]$DaysLookBack).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
        Write-Verbose "Filtering messages from: $sinceUtc"
        
        # Build filter for messages with attachments received in the lookback period
        $filter = "hasAttachments eq true and receivedDateTime ge $sinceUtc"

        # Retrieve messages with attachments expanded inline to reduce API calls
        Write-Verbose "Retrieving messages with filter: $filter"
        $messages = Get-MgUserMessage -UserId $mbx -Filter $filter -All -ExpandProperty Attachments
        
        if (-not $messages) {
            Write-Host "[$mbx] No messages found in lookback period" -ForegroundColor Yellow
            continue
        }
        
        Write-Host "[$mbx] Found $($messages.Count) message(s) with attachments" -ForegroundColor Cyan

        foreach ($m in $messages) {
            # Prefer attachments returned inline with the message to reduce API calls;
            # fallback to explicit attachment retrieval if not present
            $atts = if ($null -ne $m.Attachments -and $m.Attachments.Count -gt 0) {
                $m.Attachments
            } else {
                Get-MgUserMessageAttachment -UserId $mbx -MessageId $m.Id -All
            }

            foreach ($att in $atts) {
                # Skip non-GZIP attachments
                if ($att.Name -notmatch '(?i)\.(gz|tgz)$') {
                    Write-Host "[$mbx] Skipping attachment '$($att.Name)' (not .gz/.tgz)" -ForegroundColor DarkGray
                    continue
                }

                # Create date-based subdirectory
                $script:msgFolder = Join-Path $outDir (Get-Date -Format ddMMyyyy)
                New-Item -Path $script:msgFolder -ItemType Directory -Force | Out-Null

                # Sanitize filename to be Windows-compatible
                $fileName = ($att.Name -replace '[\\/:*?""<>|]', '_')
                $path = Join-Path $script:msgFolder $fileName

                # Attempt to retrieve attachment content from AdditionalProperties (inline expansion)
                if ($null -ne $att.AdditionalProperties -and $att.AdditionalProperties.ContainsKey('contentBytes') -and $att.AdditionalProperties.contentBytes) {
                    try {
                        $contentBytes = [System.Convert]::FromBase64String($att.AdditionalProperties.contentBytes)
                        [System.IO.File]::WriteAllBytes($path, $contentBytes)
                        Write-Host "[$mbx] Saved $path (via inline attachment)" -ForegroundColor Green
                        continue
                    } catch {
                        Write-Warning "Failed to decode inline attachment $($att.Name): $_"
                    }
                }

                # Fallback: Ensure we have the actual attachment resource (may be minimal when expanded)
                $fullAtt = $att
                if (($null -eq $fullAtt.ContentBytes -or $fullAtt.ContentBytes -eq '') -and $null -ne $fullAtt.Id) {
                    Write-Verbose "Retrieving full attachment object for: $($att.Name)"
                    $fullAtt = Get-MgUserMessageAttachment -UserId $mbx -MessageId $m.Id -AttachmentId $fullAtt.Id
                }

                # Decode ContentBytes (may be string or byte array depending on API response)
                try {
                    if ($fullAtt.ContentBytes -is [string]) {
                        $bytes = [Convert]::FromBase64String($fullAtt.ContentBytes)
                    } elseif ($fullAtt.ContentBytes -is [byte[]]) {
                        $bytes = $fullAtt.ContentBytes
                    } else {
                        $bytes = [Convert]::FromBase64String([string]$fullAtt.ContentBytes)
                    }

                    [IO.File]::WriteAllBytes($path, $bytes)
                    Write-Host "[$mbx] Saved $path" -ForegroundColor Green
                } catch {
                    Write-Error "Failed to process attachment $($att.Name) for message $($m.Id): $_"
                }
            }
        }
    }
}

<#
.SYNOPSIS
    Parses DMARC/MTA-STS JSON reports and extracts policy compliance statistics.
     
.DESCRIPTION
    Scans a directory for GZIP-compressed JSON files (.json.gz or .tgz) and extracts
    policy compliance data. Decompresses GZIP content, parses JSON structure, and builds
    a collection of policy statistics including organization name, policy domain, and
    success/failure session counts.
     
    The function expects JSON files to follow the standard DMARC aggregate report format
    which includes:
    - Organization metadata (organization-name, contact-info, report-id)
    - Date range (start-datetime, end-datetime)
    - Policy information (policy-type, policy-domain, policy-string, mx-host)
    - Summary statistics (total-successful-session-count, total-failure-session-count)
     
.PARAMETER PathToScan
    Directory path containing GZIP-compressed JSON files to parse. If not specified,
    uses the current message folder from the last download operation.
     
.EXAMPLE
    Invoke-JsonParse -PathToScan "C:\Reports\dmarc@contoso.com\04122025\"
     
    Parses all .json.gz files in the specified directory and displays a formatted table
    of policy statistics.
     
.OUTPUTS
    Returns a PSCustomObject collection with the following properties:
    - File: Original filename
    - OrgName: Reporting organization name
    - StartDatetime: Report period start
    - EndDatetime: Report period end
    - ContactInfo: Organization contact email
    - ReportId: Unique report identifier
    - PolicyType: Type of policy (e.g., 'tlsrpt', 'dmarc')
    - PolicyDomain: The domain that the policy applies to
    - PolicyString: Raw policy string
    - MXHosts: Mail server hosts covered by the policy
    - TotalSuccessfulSessions: Count of successful SMTP connections
    - TotalFailedSessions: Count of failed SMTP connections
     
.NOTES
    - Handles both compressed (.gz) and plain JSON files
    - Returns the parsed content formatted as an auto-sized table
    - Failed files are logged as warnings but processing continues
    - Stores results in script-scoped variable $script:parsedJsonContent for use by other functions
#>

function Invoke-JsonParse {
    param (
        [Parameter(Mandatory = $false)]
        [string]$PathToScan = $script:msgFolder
    )

    Write-Verbose "Parsing JSON files from: $PathToScan"
    $script:parsedJsonContent = @()

    if (-not (Test-Path $PathToScan)) {
        Write-Warning "Path not found: $PathToScan"
        return $null
    }

    $jsonFiles = Get-ChildItem -Path $PathToScan -Recurse -Include *.json.gz -File
    Write-Host "Found $($jsonFiles.Count) JSON.GZ file(s) to parse" -ForegroundColor Cyan
    
    if ($jsonFiles.Count -eq 0) {
        Write-Warning "No .json.gz files found in $PathToScan"
        return $null
    }

    $jsonFiles | ForEach-Object {
        $file = $_
        Write-Verbose "Processing file: $($file.FullName)"
        
        try {
            # Decompress GZIP file and read JSON content
            if ($file.Extension -ieq '.gz') {
                $fs = [System.IO.File]::OpenRead($file.FullName)
                $gz = New-Object System.IO.Compression.GzipStream($fs, [System.IO.Compression.CompressionMode]::Decompress)
                $sr = New-Object System.IO.StreamReader($gz)
                $text = $sr.ReadToEnd()
                $sr.Close()
                $gz.Close()
                $fs.Close()
            } else {
                $text = Get-Content -Path $file.FullName -Raw
            }

            # Parse JSON content
            $json = $text | ConvertFrom-Json

            # Iterate through policies in the report
            if ($null -ne $json.policies -and $json.policies.Count -gt 0) {
                foreach ($entry in $json.policies) {
                    $policy = $entry.policy
                    $summary = $entry.summary

                    # Create custom object with extracted data
                    $script:parsedJsonContent += [PSCustomObject]@{
                        File                     = $file.Name
                        OrgName                  = $json.'organization-name'
                        StartDatetime            = $json.'date-range'.'start-datetime'
                        EndDatetime              = $json.'date-range'.'end-datetime'
                        ContactInfo              = $json.'contact-info'
                        ReportId                 = $json.'report-id'
                        PolicyType               = $policy.'policy-type'
                        PolicyDomain             = $policy.'policy-domain'
                        PolicyString             = $policy.'policy-string'
                        MXHosts                  = $policy.'mx-host'
                        TotalSuccessfulSessions  = $summary.'total-successful-session-count'
                        TotalFailedSessions      = $summary.'total-failure-session-count'
                    }
                }
                Write-Host "[$($file.Name)] Extracted $($json.policies.Count) policies" -ForegroundColor Green
            } else {
                Write-Warning "No policies found in $($file.Name)"
            }
        } catch {
            Write-Warning "Failed to parse $($file.FullName): $_"
        }
    }

    # Display formatted summary table
    Write-Host "`nParsed Policy Summary:" -ForegroundColor Cyan
    return $script:parsedJsonContent | Select-Object PolicyDomain, OrgName, TotalFailedSessions, TotalSuccessfulSessions | Format-Table -AutoSize
}

<#
.SYNOPSIS
    Deletes downloaded report files from the specified directory.
     
.DESCRIPTION
    Removes all files from a specified directory and its subdirectories. This is useful
    for cleaning up downloaded DMARC/MTA-STS reports after processing to recover disk space
    or maintain compliance with data retention policies.
     
.PARAMETER PathToScan
    Directory path containing files to delete. If not specified, uses the current message
    folder from the last download operation. The function removes all files recursively
    from this path.
     
.EXAMPLE
    Invoke-CleanUp -PathToScan "C:\Reports\dmarc@contoso.com\04122025\"
     
    Deletes all files from the specified directory.
     
.NOTES
    - Removes files recursively from the specified directory
    - Logs deleted files in green with success messages
    - Logs failures as warnings but continues processing
    - Does not delete the directory structure itself, only files
#>

function Invoke-CleanUp {
    param (
        [Parameter(Mandatory = $false)]
        [string]$PathToScan = $script:msgFolder
    )

    Write-Verbose "Cleaning up files from: $PathToScan"

    if (-not (Test-Path $PathToScan)) {
        Write-Warning "Path not found: $PathToScan"
        return
    }

    $files = Get-ChildItem -Path $PathToScan -Recurse -File
    Write-Host "Removing $($files.Count) file(s) from $PathToScan" -ForegroundColor Cyan
    
    if ($files.Count -eq 0) {
        Write-Host "No files found to clean up" -ForegroundColor Yellow
        return
    }

    $files | ForEach-Object {
        try {
            Remove-Item -Path $_.FullName -Force
            Write-Host "Deleted: $($_.FullName)" -ForegroundColor Green
        } catch {
            Write-Warning "Failed to delete $($_.FullName): $_"
        }
    }
}

Export-ModuleMember -Function Invoke-DmarcAttachmentDownloader, Invoke-JsonParse, Invoke-CleanUp