diff --git a/Create-Shortcut.ps1 b/Create-Shortcut.ps1 new file mode 100644 index 0000000..3948666 --- /dev/null +++ b/Create-Shortcut.ps1 @@ -0,0 +1,29 @@ +<# +.Synopsis +Creates a Desktop shortcut for launching Exchange Recipient Admin Center. +.Description +Run this once from wherever this project's files actually live. It resolves +its own folder and the current user's Desktop dynamically, so the same +script works unmodified for any user/location - no hardcoded paths. +#> + +$targetFolder = Split-Path -Parent $MyInvocation.MyCommand.Path +$batPath = Join-Path $targetFolder "Launch-ExchangeRecipientAdmin.bat" +$iconPath = Join-Path $targetFolder "images\favicon.ico" +$desktop = [Environment]::GetFolderPath('Desktop') +$shortcutPath = Join-Path $desktop "Exchange Recipient Admin Center.lnk" + +$shell = New-Object -ComObject WScript.Shell +$shortcut = $shell.CreateShortcut($shortcutPath) +$shortcut.TargetPath = $batPath +$shortcut.WorkingDirectory = $targetFolder +if (Test-Path $iconPath) { + $shortcut.IconLocation = $iconPath +} +else { + $shortcut.IconLocation = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe,0" +} +$shortcut.Description = "Exchange Recipient Admin Center" +$shortcut.Save() + +Write-Host "Shortcut created at: $shortcutPath" diff --git a/Launch-ExchangeRecipientAdmin.bat b/Launch-ExchangeRecipientAdmin.bat new file mode 100644 index 0000000..2bd8882 --- /dev/null +++ b/Launch-ExchangeRecipientAdmin.bat @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Start-Process powershell.exe -Verb RunAs -ArgumentList '-ExecutionPolicy Bypass -File \"%~dp0Start-ExchangeRecipientAdminCenter.ps1\"'" diff --git a/Start-ExchangeRecipientAdminCenter.ps1 b/Start-ExchangeRecipientAdminCenter.ps1 index 9955ee4..86deeda 100644 --- a/Start-ExchangeRecipientAdminCenter.ps1 +++ b/Start-ExchangeRecipientAdminCenter.ps1 @@ -35,6 +35,135 @@ $MIMEHASH = @{".avi" = "video/x-msvideo"; ".crt" = "application/x-x509-ca-cert"; $HTML_SUCCESS = "
{result}
" $HTML_WARN = "
{result}
" +function Get-RemoteMailboxEditPage { + # Renders editremotemailbox.html for a given mailbox identity. Shared by the + # GET (initial view) and POST (after an update) handlers below. + param( + [Parameter(Mandatory)][string]$Identity, + [string]$ResultHtml = "" + ) + + $Mailbox = Get-RemoteMailbox -Identity $Identity -ErrorAction SilentlyContinue + + if (-not $Mailbox) { + return "Remote mailbox '$($Identity)' not found" + } + + # Accepted domain list, used for the "add alias" domain picker + $HTMLROWS_AD = "" + foreach ($Item in (Get-AcceptedDomain)) { + $HTMLROWS_AD += "`n" + } + + # Proxy address (EmailAddresses) rows, each with a remove button + $HTMLROWS_PROXY = "" + foreach ($Addr in $Mailbox.EmailAddresses) { + $AddrString = $Addr.ToString() + $IsPrimary = $AddrString.StartsWith("SMTP:") + if ($IsPrimary) { + $Badge = "Primary" + $RemoveBtn = "" + } + else { + $Badge = "Alias" + $RemoveBtn = " +
+ + + + +
" + } + $HTMLROWS_PROXY += " + + $($AddrString) + $($Badge) + $($RemoveBtn) + "; + } + + if ($Mailbox.HiddenFromAddressListsEnabled) { + $HiddenBadgeClass = "text-bg-warning" + $HiddenStatusText = "Hidden" + $HiddenToggleValue = "false" + $HiddenToggleLabel = "Unhide from GAL" + } + else { + $HiddenBadgeClass = "text-bg-success" + $HiddenStatusText = "Visible" + $HiddenToggleValue = "true" + $HiddenToggleLabel = "Hide from GAL" + } + + $HTMLRESPONSE = Get-Content -Path "$($BASEDIR)\editremotemailbox.html" + $HTMLRESPONSE = $HTMLRESPONSE.Replace("{DisplayName}", $Mailbox.DisplayName) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("{PrimarySmtpAddress}", $Mailbox.PrimarySmtpAddress) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("{Alias}", $Mailbox.Alias) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("{RemoteRoutingAddress}", $Mailbox.RemoteRoutingAddress) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("{id}", $Mailbox.PrimarySmtpAddress) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("{hidden_badge_class}", $HiddenBadgeClass) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("{hidden_status_text}", $HiddenStatusText) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("{hidden_toggle_value}", $HiddenToggleValue) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("{hidden_toggle_label}", $HiddenToggleLabel) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("", $HTMLROWS_PROXY) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("", $HTMLROWS_AD) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("", $ResultHtml) + return $HTMLRESPONSE +} + +function Get-RemoteMailboxListPage { + # Renders remotemailboxes.html. Used after disabling a Remote Mailbox, since + # the mailbox no longer exists to redirect back to an edit page for. + param( + [string]$ResultHtml = "" + ) + + $HTMLROWS_USERS = "" + foreach ($Item in (Get-User -Filter "RecipientType -eq 'User' -and RecipientTypeDetails -ne 'DisabledUser'" | Where { $_.UserPrincipalName })) { + $HTMLROWS_USERS += "`n" + } + + $HTMLROWS_AD = "" + foreach ($Item in (Get-AcceptedDomain)) { + if ($Item.Default) { + $HTMLROWS_AD += "`n" + } + else { + $HTMLROWS_AD += "`n" + } + } + + $HTMLROWS_RRA = "" + foreach ($Item in (Get-AcceptedDomain)) { + if ($Item.DomainName -like "*.mail.onmicrosoft.com") { + $HTMLROWS_RRA += "`n" + } + else { + $HTMLROWS_RRA += "`n" + } + } + + $HTMLROWS_MBX = "" + foreach ($Item in (Get-RemoteMailbox | Select DisplayName, PrimarySMTPAddress, RecipientTypeDetails, WhenChanged)) { + $HTMLROWS_MBX += " + + + $($Item.DisplayName) + $($Item.PrimarySMTPAddress) + $($Item.RecipientTypeDetails) + $($Item.WhenChanged) + "; + } + + $HTMLRESPONSE = Get-Content -Path "$($BASEDIR)\remotemailboxes.html" + $HTMLRESPONSE = $HTMLRESPONSE.Replace("", $HTMLROWS_MBX) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("", $HTMLROWS_AD) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("", $HTMLROWS_USERS) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("", $HTMLROWS_RRA) + $HTMLRESPONSE = $HTMLRESPONSE.Replace("", $ResultHtml) + return $HTMLRESPONSE +} + # Starting the powershell webserver "$(Get-Date -Format s) Starting Exchange Recipient Admin Webserver at: $($BINDING)" $LISTENER = New-Object System.Net.HttpListener @@ -48,6 +177,7 @@ try { "$(Get-Date -Format s) Powershell webserver started." $WEBLOG = "$(Get-Date -Format s) Powershell webserver started.`n" while ($LISTENER.IsListening) { + try { # analyze incoming request $CONTEXT = $LISTENER.GetContext() $REQUEST = $CONTEXT.Request @@ -137,14 +267,9 @@ try { "GET /editremotemailbox" { # Edit Remote Mailbox Section - $id = $REQUEST.Url.Query.Split("=")[1] - $remotemailbox = Get-RemoteMailbox -Identity $id - - $HTMLRESPONSE = (Get-Content -Path "$($BASEDIR)\editremotemailbox.html") - $HTMLRESPONSE = $HTMLRESPONSE.Replace("{DisplayName}", $remotemailbox.DisplayName) - $HTMLRESPONSE = $HTMLRESPONSE.Replace("{PrimarySmtpAddress}", $remotemailbox.PrimarySmtpAddress) - $HTMLRESPONSE = $HTMLRESPONSE.Replace("{Alias}", $remotemailbox.Alias) - $HTMLRESPONSE = $HTMLRESPONSE.Replace("{RemoteRoutingAddress}", $remotemailbox.RemoteRoutingAddress) + $id = [URI]::UnescapeDataString($REQUEST.Url.Query.TrimStart('?').Split("=")[1]) + + $HTMLRESPONSE = Get-RemoteMailboxEditPage -Identity $id break } @@ -161,15 +286,68 @@ try { $params[$key] = [System.Web.HttpUtility]::UrlDecode($value) } + # "id" is set by the alias/hidden/disable mini-forms; the main + # properties form has no id field and keys off PrimarySmtpAddress. + $Identity = if ($params.ContainsKey('id')) { $params['id'] } else { $params['PrimarySmtpAddress'] } + $Disabled = $false + try { - Set-RemoteMailbox -Identity $params['PrimarySmtpAddress'] -DisplayName $params['DisplayName'] -Alias $params['Alias'] -RemoteRoutingAddress $params['RemoteRoutingAddress'] - $HTML_RESULT = $HTML_SUCCESS.Replace("{result}", "Remote Mailbox updated successfully") + switch ($params['Action']) { + "addalias" { + $NewAlias = "$($params['alias_local'])@$($params['alias_accepteddomain'])" + Set-RemoteMailbox -Identity $Identity -EmailAddresses @{Add = $NewAlias } + $HTML_RESULT = $HTML_SUCCESS.Replace("{result}", "Added alias $NewAlias") + break + } + "removealias" { + Set-RemoteMailbox -Identity $Identity -EmailAddresses @{Remove = $params['alias'] } + $HTML_RESULT = $HTML_SUCCESS.Replace("{result}", "Removed alias $($params['alias'])") + break + } + "togglehidden" { + $NewHidden = $params['hidden'] -eq 'true' + Set-RemoteMailbox -Identity $Identity -HiddenFromAddressListsEnabled $NewHidden + $HTML_RESULT = $HTML_SUCCESS.Replace("{result}", "Set 'Hidden from address lists' to $NewHidden") + break + } + "disable" { + # Require the confirmation text to match the mailbox's actual + # PrimarySmtpAddress - checked server-side too, not just via the + # UI's type-to-confirm JS, since that's trivially bypassed. + $MailboxToDisable = Get-RemoteMailbox -Identity $Identity -ErrorAction SilentlyContinue + if (-not $MailboxToDisable) { + $HTML_RESULT = $HTML_WARN.Replace("{result}", "Remote mailbox '$($Identity)' not found") + } + elseif ($params['confirmAddress'] -ne $MailboxToDisable.PrimarySmtpAddress) { + $HTML_RESULT = $HTML_WARN.Replace("{result}", "Confirmation text didn't match $($MailboxToDisable.PrimarySmtpAddress) - no changes were made") + } + else { + Disable-RemoteMailbox -Identity $Identity -Confirm:$false + $HTML_RESULT = $HTML_SUCCESS.Replace("{result}", "Remote Mailbox for $($MailboxToDisable.PrimarySmtpAddress) has been disabled") + $Disabled = $true + } + break + } + default { + Set-RemoteMailbox -Identity $Identity -DisplayName $params['DisplayName'] -Alias $params['Alias'] -RemoteRoutingAddress $params['RemoteRoutingAddress'] + $HTML_RESULT = $HTML_SUCCESS.Replace("{result}", "Remote Mailbox updated successfully") + # DisplayName/Alias changes don't affect PrimarySmtpAddress, so it still identifies the mailbox below + $Identity = $params['PrimarySmtpAddress'] + } + } } catch { $HTML_RESULT = $HTML_WARN.Replace("{result}", $Error -join "
") } - $HTMLRESPONSE = (Get-Content -Path "$($BASEDIR)\remotemailboxes.html").Replace("", $HTML_RESULT) + # A disabled mailbox no longer exists to render an edit page for - + # send the user back to the list instead. + if ($Disabled) { + $HTMLRESPONSE = Get-RemoteMailboxListPage -ResultHtml $HTML_RESULT + } + else { + $HTMLRESPONSE = Get-RemoteMailboxEditPage -Identity $Identity -ResultHtml $HTML_RESULT + } break } @@ -515,6 +693,15 @@ try { "$(Get-Date -Format s) Stopping powershell webserver..." break; } + } + catch { + # A single request failing (e.g. client closed the connection before the + # response finished writing - "network name is no longer available") should + # not take down the whole webserver. Log it and keep listening. + "$(Get-Date -Format s) Request error: $($_.Exception.Message)" + $WEBLOG += "$(Get-Date -Format s) Request error: $($_.Exception.Message)`n" + try { $RESPONSE.Close() } catch { } + } } } finally { diff --git a/web/accepteddomains.html b/web/accepteddomains.html index 106d204..6a59c67 100644 --- a/web/accepteddomains.html +++ b/web/accepteddomains.html @@ -163,6 +163,7 @@ + diff --git a/web/distributiongroups.html b/web/distributiongroups.html index 47ea399..82edc6b 100644 --- a/web/distributiongroups.html +++ b/web/distributiongroups.html @@ -236,6 +236,7 @@ + diff --git a/web/table-tools.js b/web/table-tools.js new file mode 100644 index 0000000..3e23932 --- /dev/null +++ b/web/table-tools.js @@ -0,0 +1,78 @@ +// Adds a client-side search box and clickable sortable column headers to +// every on the page. Purely client-side - it only +// reorders/hides rows already rendered by the server, no data is refetched. +(function () { + function cellText(cell) { + return cell.textContent.trim().toLowerCase(); + } + + function sortTable(table, colIndex, ascending) { + var tbody = table.tBodies[0]; + var rows = Array.prototype.slice.call(tbody.rows); + rows.sort(function (a, b) { + var av = cellText(a.cells[colIndex]); + var bv = cellText(b.cells[colIndex]); + if (av < bv) return ascending ? -1 : 1; + if (av > bv) return ascending ? 1 : -1; + return 0; + }); + rows.forEach(function (row) { tbody.appendChild(row); }); + } + + function addSortHandlers(table) { + var headerRow = table.tHead && table.tHead.rows[0]; + if (!headerRow) return; + + Array.prototype.forEach.call(headerRow.cells, function (th, index) { + if (!th.textContent.trim()) return; // skip empty/action columns + + th.style.cursor = 'pointer'; + th.dataset.sortDir = ''; + + th.addEventListener('click', function () { + var ascending = th.dataset.sortDir !== 'asc'; + + Array.prototype.forEach.call(headerRow.cells, function (sib) { + sib.dataset.sortDir = ''; + sib.textContent = sib.textContent.replace(/ [▲▼]$/, ''); + }); + + th.dataset.sortDir = ascending ? 'asc' : 'desc'; + th.textContent = th.textContent.replace(/ [▲▼]$/, '') + (ascending ? ' ▲' : ' ▼'); + + sortTable(table, index, ascending); + }); + }); + } + + function addSearchBox(table) { + var wrapper = document.createElement('div'); + wrapper.className = 'mb-2'; + + var input = document.createElement('input'); + input.type = 'search'; + input.className = 'form-control'; + input.placeholder = 'Search...'; + wrapper.appendChild(input); + + table.parentNode.insertBefore(wrapper, table); + + input.addEventListener('input', function () { + var query = input.value.trim().toLowerCase(); + var rows = table.tBodies[0].rows; + Array.prototype.forEach.call(rows, function (row) { + var text = row.textContent.toLowerCase(); + row.style.display = text.indexOf(query) === -1 ? 'none' : ''; + }); + }); + } + + document.addEventListener('DOMContentLoaded', function () { + var tables = document.querySelectorAll('table.table'); + Array.prototype.forEach.call(tables, function (table) { + if (!table.tHead || !table.tBodies.length) return; + addSearchBox(table); + addSortHandlers(table); + }); + }); +})();