EasyEntraPS.psm1

# EasyEntraPS.psm1
Add-Type -Path (Join-Path $PSScriptRoot './bin/EasyEntra.Core.dll')

# Default output properties for cmdlets in the module.
$PSDefaultParameterValues = @{
    'Get-EEMailbox:Property' = 'DisplayName'
}

# Shared helper function for TaskLog formatting
function Format-EETaskLogEntries {
    param(
        [Parameter(Mandatory)]
        [System.Text.StringBuilder]$StringBuilder,
        
        [Parameter(Mandatory)]
        $TaskLog
    )
    
    if ($null -eq $TaskLog -or $TaskLog.Count -eq 0) {
        return
    }
    
    $logEntries = @($TaskLog)
    $maxStatusWidth = ($logEntries | ForEach-Object { $_.Status.ToString().Length } | Measure-Object -Maximum).Maximum
    
    for ($i = 0; $i -lt $logEntries.Count; $i++) {
        $entry = $logEntries[$i]
        try {
            $timeStr = $entry.Time.ToString('HH:mm:ss')
            $statusStr = $entry.Status.ToString().PadRight($maxStatusWidth)
            [void]$StringBuilder.AppendLine(" [$timeStr] $statusStr $($entry.Task)")
            
            if (-not [string]::IsNullOrWhiteSpace($entry.Message)) {
                $messageIndent = ' ' * ($timeStr.Length + $maxStatusWidth + 7)
                [void]$StringBuilder.AppendLine("$messageIndent> $($entry.Message)")
            }
        }
        catch {
            [void]$StringBuilder.AppendLine(" [Error: $($_.Exception.Message)]")
        }
    }
}

# Shared helper function for progress reporting
function New-EEProgressAction {
    param([string]$Activity)
    
    return [System.Action[EasyEntra.Core.DataModel.TaskLogItem]]{
        param($item)
        $ts = $item.Time
        $task = $item.Task
        $status = $item.Status.ToString()
        $msg = if ($item.Message) { $item.Message } else { "" }

        $line = "{0}`t{1}`t{2}`t{3}" -f $ts, $task, $status, $msg
        Write-Information $line

        if ($null -ne $item.PercentComplete) {
            Write-Progress -Activity $Activity -Status $item.Task -PercentComplete $item.PercentComplete
        } else {
            Write-Progress -Activity $Activity -Status $item.Task
        }
    }
}

# Shared helper: print the sponsor banner after a successful (non-fatal) real export
# (free use only; Core decides and returns $null otherwise)
function Show-EESponsorBanner {
    param([Parameter(Mandatory)] $CustObj)

    $banner = $CustObj.GetSponsorBanner()
    if ($banner) { Write-Host $banner -ForegroundColor DarkCyan }
}

# Defining functions that use the DLL
function Get-EEMailbox{
[CmdletBinding(DefaultParameterSetName='Identity')]
  param(
    [Parameter(ParameterSetName='AnrSet')]
    [ValidateLength(3, 5120)]
    [string]
    ${Anr},

    [switch]
    ${Archive},

    [ValidateNotNullOrEmpty()]
    [string]
    ${Filter},

    [switch]
    ${GroupMailbox},

    [Parameter(ParameterSetName='Identity', Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
    ${Identity},

    [switch]
    ${InactiveMailboxOnly},

    [switch]
    ${IncludeEmailAddressDisplayNames},

    [switch]
    ${IncludeInactiveMailbox},

    [Parameter(ParameterSetName='MailboxPlanSet', ValueFromPipeline=$true)]
    [ValidateNotNullOrEmpty()]
    ${MailboxPlan},

    [switch]
    ${Migration},

    ${OrganizationalUnit},

    [switch]
    ${PublicFolder},

    [ValidateNotNullOrEmpty()]
    [System.Object[]]
    ${RecipientTypeDetails},

    ${ResultSize},

    [switch]
    ${ServiceSafetyConfiguration},

    [switch]
    ${SoftDeletedMailbox},

    [string]
    ${SortBy},

    [string]
    ${Tenant}
  )

  process {
    $myArgs = @{}

    foreach ($key in $MyInvocation.BoundParameters.Keys) {
      $myArgs.Add($key, $MyInvocation.BoundParameters[$key])
    }

    $custObj = New-Object EasyEntra.Core.PS
    Write-Output $custObj.GetMailbox($myArgs)
  }
}

Function Reset-EEConfiguration{
  $custObj = New-Object EasyEntra.Core.PS
  Write-Output $custObj.ResetConfiguration()
}

Function Import-EEOfflineLicense{
  param (
    [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $true)]
    [string]$Path
  )
  process {
    $custObj = New-Object EasyEntra.Core.PS
    Write-Output $custObj.ImportLicense($Path)
  }
}

Function Invoke-EEDecommissionHybridUser {
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
  param(
    [Parameter(Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
    [string]$Identity,

    [Parameter()]
    [string]$Domain
  )

  begin {
    $custObj = New-Object EasyEntra.Core.PS
    $activity = "EasyEntra decommission (hybrid)"
    $progressAction = New-EEProgressAction -Activity $activity
  }

  process {
    if (-not $Identity) {
      throw "Identity is required. Provide UPN, SamAccountName, SID, or Guid."
    }

    $target = if ($Domain) { "$Identity (Domain: $Domain)" } else { "$Identity" }

    if ($PSCmdlet.ShouldProcess($target, "Decommission Hybrid user")) {
        $collection = $custObj.DecommissionHybridUser(
          $Identity,
          $Domain,
          $progressAction,
          $true  # Execute (user confirmed or no confirmation needed)
        )
        
        Write-Progress -Activity $activity -Completed
        $collection[0]
    }
    elseif ($WhatIfPreference) {
        $collection = $custObj.DecommissionHybridUser(
          $Identity,
          $Domain,
          $progressAction,
          $false  # Simulate only (WhatIf)
        )
        
        Write-Progress -Activity $activity -Completed
        $collection[0]
    }
    else {
        # User declined confirmation - do nothing
        Write-Progress -Activity $activity -Completed
        Write-Verbose "Operation cancelled by user"
    }
  }

  end {
    if ($custObj -and $custObj -is [System.IDisposable]) {
      $custObj.Dispose()
    }
  }
}

Function Invoke-EEDecommissionEntraUser {
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
  param(
    [Parameter(Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
    [string]$Identity,

    [Parameter()]
    [string]$Tenant
  )

  begin {
    $custObj = New-Object EasyEntra.Core.PS
    $activity = "EasyEntra decommission (Entra)"
    $progressAction = New-EEProgressAction -Activity $activity
  }

  process {
    if (-not $Identity) {
      throw "Identity is required. Provide UPN or Guid."
    }

    $target = if ($Tenant) { "$Identity (Tenant: $Tenant)" } else { "$Identity" }

    if ($PSCmdlet.ShouldProcess($target, "Decommission Entra user")) {
        $collection = $custObj.DecommissionEntraUser(
          $Identity,
          $Tenant,
          $progressAction,
          $true  # Execute (user confirmed or no confirmation needed)
        )
        
        Write-Progress -Activity $activity -Completed
        $collection[0]
    }
    elseif ($WhatIfPreference) {
        $collection = $custObj.DecommissionEntraUser(
          $Identity,
          $Tenant,
          $progressAction,
          $false  # Simulate only (WhatIf)
        )
        
        Write-Progress -Activity $activity -Completed
        $collection[0]
    }
    else {
        # User declined confirmation - do nothing
        Write-Progress -Activity $activity -Completed
        Write-Verbose "Operation cancelled by user"
    }
  }
  end {
    if ($custObj -and $custObj -is [System.IDisposable]) {
      $custObj.Dispose()
    }
  }
}

Function Invoke-EECreateHybridUserFromTemplate {
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
  param(
    [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
    [string]$TemplateName,

    [Parameter(Mandatory = $true, Position = 1, ValueFromPipelineByPropertyName = $true)]
    [string]$DisplayName,

    [Parameter()]
    [string]$Domain
  )

  begin {
    $custObj  = New-Object EasyEntra.Core.PS
    $activity = "EasyEntra create user (hybrid from template)"
    $progressAction = New-EEProgressAction -Activity $activity
  }

  process {
    if (-not $TemplateName) {
      throw "TemplateName is required."
    }

    if (-not $DisplayName) {
      throw "DisplayName is required."
    }

    $target = if ($Domain) {
      "$DisplayName (Template: $TemplateName, Domain: $Domain)"
    } else {
      "$DisplayName (Template: $TemplateName)"
    }

    if ($PSCmdlet.ShouldProcess($target, "Create hybrid user from template")) {
        $collection = $custObj.CreateHybridUserFromTemplate(
            $TemplateName,
            $DisplayName,
            $Domain,
            $progressAction,
            $true  # Execute (user confirmed or no confirmation needed)
        )
        
        Write-Progress -Activity $activity -Completed
        $collection[0]
    }
    elseif ($WhatIfPreference) {
        $collection = $custObj.CreateHybridUserFromTemplate(
            $TemplateName,
            $DisplayName,
            $Domain,
            $progressAction,
            $false  # Simulate only (WhatIf)
        )
        
        Write-Progress -Activity $activity -Completed
        $collection[0]
    }
    else {
        # User declined confirmation - do nothing
        Write-Progress -Activity $activity -Completed
        Write-Verbose "Operation cancelled by user"
    }
  }

  end {
    if ($custObj -and $custObj -is [System.IDisposable]) {
      $custObj.Dispose()
    }
  }
}

Function Invoke-EECreateEntraUserFromTemplate {
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
  param(
    [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
    [string]$TemplateName,

    [Parameter(Mandatory = $true, Position = 1, ValueFromPipelineByPropertyName = $true)]
    [string]$DisplayName,

    [Parameter()]
    [string]$Tenant
  )

  begin {
    $custObj  = New-Object EasyEntra.Core.PS
    $activity = "EasyEntra create user (Entra from template)"
    $progressAction = New-EEProgressAction -Activity $activity
  }

  process {
    if (-not $TemplateName) {
      throw "TemplateName is required."
    }

    if (-not $DisplayName) {
      throw "DisplayName is required."
    }

    $target = if ($Tenant) {
      "$DisplayName (Template: $TemplateName, Tenant: $Tenant)"
    } else {
      "$DisplayName (Template: $TemplateName)"
    }

    if ($PSCmdlet.ShouldProcess($target, "Create Entra user from template")) {
        $collection = $custObj.CreateEntraUserFromTemplate(
            $TemplateName,
            $DisplayName,
            $Tenant,
            $progressAction,
            $true  # Execute (user confirmed or no confirmation needed)
        )        
        Write-Progress -Activity $activity -Completed
        $collection[0]
    }
    elseif ($WhatIfPreference) {
        $collection = $custObj.CreateEntraUserFromTemplate(
            $TemplateName,
            $DisplayName,
            $Tenant,
            $progressAction,
            $false  # Simulate only (WhatIf)
        )        
        Write-Progress -Activity $activity -Completed
        $collection[0]
    }
    else {
        # User declined confirmation - do nothing
        Write-Progress -Activity $activity -Completed
        Write-Verbose "Operation cancelled by user"
    }
  }

  end {
    if ($custObj -and $custObj -is [System.IDisposable]) {
      $custObj.Dispose()
    }
  }
}

Function Invoke-EEMailboxToPst {
  <#
  .SYNOPSIS
  Exports Exchange Online mailbox email to a local PST file.
 
  .DESCRIPTION
  Retrieves mailbox email through Microsoft Graph and exports it to a local PST file using EasyEntra's own PST writer, without requiring Outlook, MAPI, a paid PST library or a Purview workflow.
 
  The signed-in user must have Full Access to the mailbox. Grant this permission 15 minutes to one hour before starting the export, allowing Microsoft Graph to recognize the permission assigned in Exchange Online. Actual propagation time can vary.
 
  PST export includes email only. Contacts and calendar items are not exported.
 
  This command is free for tenants of any size, including commercial administration. Resale of command access is prohibited.
 
  Check the reported completion status and verify the contents of the resulting PST file before relying on the export.
 
  .EXAMPLE
  Invoke-EEMailboxToPst -Identity 'user@contoso.com' -Filename 'C:\Exports\user.pst'
 
  Exports the specified mailbox's email to the local PST file.
 
  .LINK
  https://easyentra.com/knowledge-base/how-to/how-to-export-an-exchange-online-mailbox-to-pst-via-powershell/
  #>

  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
  param(
    [Parameter(Mandatory = $true, Position = 0,
               ValueFromPipeline = $true,
               ValueFromPipelineByPropertyName = $true)]
    [string]$Identity,

    [Parameter()]
    [string]$Tenant,

    [Parameter()]
    [bool]$ShowBanner = $true,

    [Parameter()]
    [string]$FileName,

    [Parameter()]
    [Nullable[DateTime]]$StartDate,

    [Parameter()]
    [Nullable[DateTime]]$EndDate,

    [Parameter()]
    [ValidateSet('Global', 'USGov', 'USGovDod')]
    [string]$Environment = 'Global'
  )

  begin {
    $custObj = New-Object EasyEntra.Core.PS
    $exportSucceeded = $false
    $activity = "EasyEntra export mailbox to PST"
    $progressAction = New-EEProgressAction -Activity $activity

    # Only wire verbose callback when -Verbose is active.
    # When null, _verbose?.Invoke() in C# is a true no-op with zero overhead.
    # When always wired, Write-Verbose fires per message even when discarded,
    # adding ~0.25s/message overhead through WrapVerboseWithRunspace.
    $verboseAction = if ($VerbosePreference -eq 'Continue') {
        [System.Action[string]]{ param($msg) Write-Verbose $msg }
    } else {
        $null
    }
  }

  process {
    if (-not $Identity) {
      throw "Identity is required. Provide a mailbox UPN."
    }

    # Resolve output path fully in PowerShell.
    # C# must not call Directory.GetCurrentDirectory() — it returns
    # the pwsh.exe process directory, not the user's working directory.
    $pwdPath = (Get-Location).Path

    if ([string]::IsNullOrWhiteSpace($FileName)) {
        # Compute default filename from Identity + timestamp
        $safeIdentity = $Identity -replace '@','_' -replace '\.','_'
        $timestamp = Get-Date -Format 'yyMMdd_HHmmss'
        $resolvedFileName = [System.IO.Path]::Combine(
            $pwdPath, "${safeIdentity}_${timestamp}.pst")
    } elseif ([System.IO.Path]::IsPathRooted($FileName)) {
        $resolvedFileName = $FileName
    } else {
        $resolvedFileName = [System.IO.Path]::Combine($pwdPath, $FileName)
    }

    $targetParts = @($Identity)
    if ($Tenant)    { $targetParts += "Tenant: $Tenant" }
    $targetParts += "File: $resolvedFileName"
    if ($StartDate) { $targetParts += "From: $($StartDate.ToString('yyyy-MM-dd'))" }
    if ($EndDate)   { $targetParts += "To: $($EndDate.ToString('yyyy-MM-dd'))" }
    if ($Environment -ne 'Global') { $targetParts += "Env: $Environment" }
    $target = $targetParts -join ', '

    if ($PSCmdlet.ShouldProcess($target, "Export mailbox to PST")) {
        $collection = $custObj.ExportMailboxToPst(
          $Identity,
          $Tenant,
          $resolvedFileName,
          $StartDate,
          $EndDate,
          $Environment,
          $progressAction,
          $verboseAction,
          $false    # execute = true (not WhatIf)
        )
        Write-Progress -Activity $activity -Completed
        $result = $collection[0]
        $result
        if ($result.Status -in 'Completed', 'CompletedWithWarning', 'CompletedWithError') {
            $exportSucceeded = $true
        }
    }
    elseif ($WhatIfPreference) {
        $collection = $custObj.ExportMailboxToPst(
          $Identity,
          $Tenant,
          $resolvedFileName,
          $StartDate,
          $EndDate,
          $Environment,
          $progressAction,
          $verboseAction,
          $true     # execute = false (WhatIf)
        )
        Write-Progress -Activity $activity -Completed
        $collection[0]
    }
    else {
        Write-Progress -Activity $activity -Completed
        Write-Verbose "Operation cancelled by user"
    }
  }

  end {
    if ($ShowBanner -and $exportSucceeded) { Show-EESponsorBanner $custObj }
    if ($custObj -and $custObj -is [System.IDisposable]) {
      $custObj.Dispose()
    }
  }
}

Function Invoke-EEOneDriveToZip {
  <#
  .SYNOPSIS
  Exports a user's Microsoft 365 OneDrive files and folders to ZIP files.
 
  .DESCRIPTION
  Exports the specified user's OneDrive content to local ZIP files for offline storage, archiving or migration.
 
  The command supports modification-date filtering, maximum file-size filtering and splitting the export into multiple ZIP files. See the online guide for authentication, required permissions, output configuration and export options.
 
  This command is free for tenants of any size, including commercial administration. Resale of command access is prohibited.
 
  Verify the contents of the resulting ZIP files before relying on the export.
 
  .EXAMPLE
  Invoke-EEOneDriveToZip -Identity 'user@contoso.com'
 
  Exports the specified user's OneDrive using the configured output settings.
 
  .LINK
  https://easyentra.com/knowledge-base/how-to/how-to-export-onedrive-with-powershell/
  #>

  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
  param(
    [Parameter(Mandatory = $true, Position = 0,
               ValueFromPipeline = $true,
               ValueFromPipelineByPropertyName = $true)]
    [string]$Identity,

    [Parameter()]
    [string]$Tenant,

    [Parameter()]
    [bool]$ShowBanner = $true,

    [Parameter()]
    [string]$FileName,

    [Parameter()]
    [Nullable[DateTime]]$ModifiedAfter,

    [Parameter()]
    [Nullable[DateTime]]$ModifiedBefore,

    [Parameter()]
    [Nullable[Int64]]$MaxFileSizeMB,

    [Parameter()]
    [Nullable[Int64]]$SplitSizeMB,

    [Parameter()]
    [ValidateSet('Global', 'USGov', 'USGovDod')]
    [string]$Environment = 'Global'
  )

  begin {
    $custObj = New-Object EasyEntra.Core.PS
    $exportSucceeded = $false
    $activity = "EasyEntra export OneDrive to zip"
    $progressAction = New-EEProgressAction -Activity $activity

    # Only wire verbose callback when -Verbose is active.
    # When null, _verbose?.Invoke() in C# is a true no-op with zero overhead.
    # When always wired, Write-Verbose fires per message even when discarded,
    # adding ~0.25s/message overhead through WrapVerboseWithRunspace.
    $verboseAction = if ($VerbosePreference -eq 'Continue') {
        [System.Action[string]]{ param($msg) Write-Verbose $msg }
    } else {
        $null
    }
  }

  process {
    if (-not $Identity) {
      throw "Identity is required. Provide a user UPN."
    }

    # Resolve output path fully in PowerShell.
    # C# must not call Directory.GetCurrentDirectory() — it returns
    # the pwsh.exe process directory, not the user's working directory.
    $pwdPath = (Get-Location).Path

    if ([string]::IsNullOrWhiteSpace($FileName)) {
        # Compute default filename from Identity + timestamp
        $safeIdentity = $Identity -replace '@','_' -replace '\.','_'
        $timestamp = Get-Date -Format 'yyMMdd_HHmmss'
        $resolvedFileName = [System.IO.Path]::Combine(
            $pwdPath, "${safeIdentity}_${timestamp}.zip")
    } elseif ([System.IO.Path]::IsPathRooted($FileName)) {
        $resolvedFileName = $FileName
    } else {
        $resolvedFileName = [System.IO.Path]::Combine($pwdPath, $FileName)
    }

    $targetParts = @($Identity)
    if ($Tenant)         { $targetParts += "Tenant: $Tenant" }
    $targetParts += "File: $resolvedFileName"
    if ($ModifiedAfter)  { $targetParts += "From: $($ModifiedAfter.ToString('yyyy-MM-dd'))" }
    if ($ModifiedBefore) { $targetParts += "To: $($ModifiedBefore.ToString('yyyy-MM-dd'))" }
    if ($MaxFileSizeMB)  { $targetParts += "MaxFileSizeMB: $MaxFileSizeMB" }
    if ($SplitSizeMB)    { $targetParts += "SplitSizeMB: $SplitSizeMB" }
    if ($Environment -ne 'Global') { $targetParts += "Env: $Environment" }
    $target = $targetParts -join ', '

    if ($PSCmdlet.ShouldProcess($target, "Export OneDrive to zip")) {
        $collection = $custObj.ExportOneDriveToZip(
          $Identity,
          $Tenant,
          $resolvedFileName,
          $ModifiedAfter,
          $ModifiedBefore,
          $MaxFileSizeMB,
          $SplitSizeMB,
          $Environment,
          $progressAction,
          $verboseAction,
          $false    # whatIf = false (execute)
        )
        Write-Progress -Activity $activity -Completed
        $result = $collection[0]
        $result
        if ($result.Status -in 'Completed', 'CompletedWithWarning', 'CompletedWithError') {
            $exportSucceeded = $true
        }
    }
    elseif ($WhatIfPreference) {
        $collection = $custObj.ExportOneDriveToZip(
          $Identity,
          $Tenant,
          $resolvedFileName,
          $ModifiedAfter,
          $ModifiedBefore,
          $MaxFileSizeMB,
          $SplitSizeMB,
          $Environment,
          $progressAction,
          $verboseAction,
          $true     # whatIf = true (Phase 1 only, no zip)
        )
        Write-Progress -Activity $activity -Completed
        $collection[0]
    }
    else {
        Write-Progress -Activity $activity -Completed
        Write-Verbose "Operation cancelled by user"
    }
  }

  end {
    if ($ShowBanner -and $exportSucceeded) { Show-EESponsorBanner $custObj }
    if ($custObj -and $custObj -is [System.IDisposable]) {
      $custObj.Dispose()
    }
  }
}

# Export the functions
Export-ModuleMember -Function @(
  "Get-EEMailbox", 
  "Reset-EEConfiguration", 
  "Import-EEOfflineLicense",
  "Invoke-EEDecommissionHybridUser",
  "Invoke-EEDecommissionEntraUser",
  "Invoke-EECreateHybridUserFromTemplate",
  "Invoke-EECreateEntraUserFromTemplate",
  "Invoke-EEMailboxToPst",
  "Invoke-EEOneDriveToZip",
  "Format-EETaskLogEntries"
  )