Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,12 @@ covers when `[Unreleased]` becomes a version and how that version is chosen.
entry also adds `## [Unreleased](https://github.com/Sekers/ScriptMessage/compare/<release>...develop)` at the
top, where `<release>` is `$Release` from the baseline commands above. Never add an empty one, and never leave
one in a release: the Release workflow refuses to publish while it is there.
- Newest version first. A released version uses this heading shape, and a `---` line separates it from the next
(older) version:
- Newest version first. A released version uses this heading shape, and a `---` line, with a blank line on each
side of it, separates it from the next (older) version:

```markdown
---

## [1.2.0](https://github.com/Sekers/ScriptMessage/tree/1.2.0) - 2026-09-20

### Fixed
Expand All @@ -145,8 +147,9 @@ covers when `[Unreleased]` becomes a version and how that version is chosen.
- Prefix an entry with `Minor:` inside **Fixed** when a user probably never noticed it: help text wording, a
message, an edge case needing unusual conditions to hit, or a cost the module absorbed itself such as an
extra Microsoft Graph request. List the `Minor:` entries after the rest of the Fixed entries.
- Prefix an entry with `BREAKING CHANGE:` when it can break an existing script or configuration file, and say
what the user has to change. Such an entry usually belongs under **Changed** or **Removed**.
- Prefix an entry with `BREAKING CHANGE:` when it breaks the public contract as [RELEASING.md](./RELEASING.md)
defines it, so an existing script or configuration file can stop working, and say what the user has to
change. Such an entry usually belongs under **Changed** or **Removed**.
- A new public function goes under **Added** and reads `- New Function: <Function-Name> > <what it does>`.
- **No em dashes.** Use a semicolon, colon, parentheses, comma, or a new sentence. This applies to every file
in the repo, not just the changelog.
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
- New Parameter: `Send-ScriptMessage -MailType` > Chooses how an email with more than one recipient is sent. It overrides the configuration file's `MailType` setting, and `Group` is used when neither one sets it.
- `Group` sends one email to all of the To, CC, and BCC recipients.
- `OneOnOne` sends each To, CC, and BCC recipient a separate email with only that recipient in To, so no recipient can see who else received it. A recipient listed more than once gets one email.
- New Parameter: `Get-ScriptMessageConfig -ReturnConfigFilePath` > Adds a `ConfigFilePath` property to the returned settings, holding the full path of the configuration file that was read.

### Changed

- Importing ScriptMessage again with `Import-Module -Force`, or removing and importing it, now clears the configuration file path set by `Set-ScriptMessageConfigFilePath`. Call `Set-ScriptMessageConfigFilePath` after importing the module.

### Fixed

Expand All @@ -24,6 +29,7 @@
- `MgApp_CertificatePath` ran any PowerShell code written into it, so anyone who could edit the configuration file could run commands as the account sending messages. This affected every configuration file on PowerShell 7.4 and later, whatever authentication it was set up for. Only environment variables written as `$env:NAME` or `${env:NAME}` are expanded now, and the rest of the path is used exactly as written.

---

## [1.1.1](https://github.com/Sekers/ScriptMessage/tree/1.1.1) - 2026-09-16

### Changed
Expand All @@ -44,6 +50,7 @@
- Minor: A configuration file whose path contains square brackets, such as a folder named `Scripts [old]`, can now be used. `Get-ScriptMessageConfig`, `Connect-ScriptMessage`, and `Send-ScriptMessage` stopped with "Can't find the JSON configuration file" and sent nothing.

---

## [1.1.0](https://github.com/Sekers/ScriptMessage/tree/1.1.0) - (2025-10-16)

### Fixes
Expand All @@ -68,6 +75,7 @@
Author: [**@Sekers**](https://github.com/Sekers)

---

## [1.0.8](https://github.com/Sekers/ScriptMessage/tree/1.0.8) - (2025-09-18)

### Fixes
Expand All @@ -90,6 +98,7 @@ Author: [**@Sekers**](https://github.com/Sekers)
Author: [**@Sekers**](https://github.com/Sekers)

---

## [1.0.7](https://github.com/Sekers/ScriptMessage/tree/1.0.7) - (2024-12-26)

### Features
Expand All @@ -103,6 +112,7 @@ Author: [**@Sekers**](https://github.com/Sekers)
Author: [**@Sekers**](https://github.com/Sekers)

---

## [1.0.6](https://github.com/Sekers/ScriptMessage/tree/1.0.6) - (2024-12-19)

### Fixes
Expand All @@ -123,6 +133,7 @@ Author: [**@Sekers**](https://github.com/Sekers)
Author: [**@Sekers**](https://github.com/Sekers)

---

## [1.0.5](https://github.com/Sekers/ScriptMessage/tree/1.0.5) - (2024-05-22)

### Features
Expand All @@ -132,6 +143,7 @@ Author: [**@Sekers**](https://github.com/Sekers)
Author: [**@Sekers**](https://github.com/Sekers)

---

## [1.0.4](https://github.com/Sekers/ScriptMessage/tree/1.0.4) - (2024-05-21)

### Fixes
Expand All @@ -141,6 +153,7 @@ Author: [**@Sekers**](https://github.com/Sekers)
Author: [**@Sekers**](https://github.com/Sekers)

---

## [1.0.3](https://github.com/Sekers/ScriptMessage/tree/1.0.3) - (2024-05-21)

### Features
Expand All @@ -150,6 +163,7 @@ Author: [**@Sekers**](https://github.com/Sekers)
Author: [**@Sekers**](https://github.com/Sekers)

---

## [1.0.2](https://github.com/Sekers/ScriptMessage/tree/1.0.2) - (2024-05-21)

### Fixes
Expand All @@ -159,6 +173,7 @@ Author: [**@Sekers**](https://github.com/Sekers)
Author: [**@Sekers**](https://github.com/Sekers)

---

## [1.0.1](https://github.com/Sekers/ScriptMessage/tree/1.0.1) - (2024-05-20)

### Fixes
Expand All @@ -168,6 +183,7 @@ Author: [**@Sekers**](https://github.com/Sekers)
Author: [**@Sekers**](https://github.com/Sekers)

---

## [1.0.0](https://github.com/Sekers/ScriptMessage/tree/1.0.0) - (2024-05-20)

### Features
Expand Down
14 changes: 13 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ uses the stricter Semantic Versioning rule: new functionality, including a new p

### The public contract

The public contract is anything a caller can observe or depend on:
The public contract is the behavior ScriptMessage offers its callers:

- Exported functions and aliases, their parameters and parameter sets, and each parameter's requirements,
defaults, validation, and meaning.
Expand All @@ -35,11 +35,23 @@ The public contract is anything a caller can observe or depend on:
- The external modules the module requires, such as the Microsoft Graph sub-modules each message type needs.
- The permissions the module requests or needs, such as Microsoft Graph scopes.

A side effect of how the module happens to be built is not part of it, even when a caller can observe it. See
[Deciding hard cases](#deciding-hard-cases).

### Deciding hard cases

- **A fix that changes observable behavior** is a patch when the old behavior could not have worked for anyone,
such as rejecting recipients that have no address, which never sent anything. It is a breaking change when a
reasonable script could have depended on the old behavior. Either way, the changelog entry says what changed.
- **Correcting an unintended side effect is not a breaking change.** Behavior that exists only because of how the
module was built, and that no documentation ever offered (the help, the README, the wiki, the sample
configuration file, or the changelog), is outside the public contract even though a caller can observe or use
it. Examples: a variable the module kept in the global scope, where any script could read or change it, and a
true/false setting written as quoted text. Changing or removing such behavior needs no deprecation, and the
"reasonable script" test above does not apply. Naming something in an error message does not document it.
Behavior the module intends callers to use stays in the contract even where its documentation is thin, such as
the properties of the `Send-ScriptMessage` result. The change gets a changelog entry only when a script that
follows the documentation could notice it.
- **Requiring a new module or permission is a breaking change**, because existing installations and app
registrations stop working until someone acts.
- **Deprecate before removing.** Mark functionality as deprecated in a minor release (documentation, a warning,
Expand Down
32 changes: 32 additions & 0 deletions Research_Notes/PowerShell-Language-Behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,3 +406,35 @@ empty configuration file reads as `$null` without an error in both editions.
stopped at, so an unquoted secret would appear in it.
- What PowerShell 7 builds from the input it accepts but 5.1 rejects was not examined.
- A file the account has no permission to read was not tested, only a folder given as the path.

## 12. Measured: module-scope state across imports

Measured on **2026-09-22**, same machine and editions as the header. Unlike the other sections, this one imported
a module, each run in a new `-NoProfile` process: an in-memory module built with `New-Module` for the first and
last rows, and this repository's module for the rest, loaded through `PSModulePath` so that it loaded automatically. At the
time, the module kept its cached context in a global variable that the .psm1 recreates on every import, so a
marker property added to that object showed whether a later import ran the .psm1 again.

| Situation | Result |
| --- | --- |
| A function's default parameter value reads `$script:X`, and the function is called from the caller's scope or from a child scope | reads the module's value in both cases; `Get-Variable -Scope Global` does not show the variable |
| `Import-Module ScriptMessage` without `-Force`, after calling one of its functions loaded it automatically | the .psm1 does not run again; one module instance |
| Another module's code runs `Import-Module ScriptMessage` while it is loaded | the .psm1 does not run again; still one instance |
| `Import-Module ScriptMessage -Force` | the .psm1 runs again, so module-scope state starts over; one instance |
| `(Get-Module ScriptMessage).ExportedVariables` with `VariablesToExport = '*'` in the manifest and no `Export-ModuleMember` in the .psm1 | empty |
| `& (Get-Module <name>) { $script:X }` from outside the module | returns the module's value |

So module-scope state survives everything except a forced import (or `Remove-Module` followed by an import),
while a global variable survives even that.

**From source:** `Set-ScriptMessageConfigFilePath` stores the path in `$script:ScriptMessageConfigFilePath`, which
`ScriptMessage.psm1` declares, and `Get-ScriptMessageConfig` uses it as the default for `-Path`, so importing the
module again with `-Force` clears the path. `Get-ScriptMessageContext` and `Disconnect-ScriptMessage` share
`$script:ScriptMessageCachedServiceContext` the same way. `ScriptMessage.psd1` sets `VariablesToExport` to an
empty list, which the last-but-one row shows changes nothing for callers. The last row is not a supported way to
read the path; `Get-ScriptMessageConfig -ReturnConfigFilePath` is.

### What this does not establish

- Two versions of the module loaded side by side, and `Import-Module -Scope Local`, were not tested.
- Other runspaces (jobs, `ForEach-Object -Parallel`) were not tested.
2 changes: 1 addition & 1 deletion ScriptMessage/Public/Disconnect-ScriptMessage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function Disconnect-ScriptMessage
# Drop this service's cached context. 'Get-ScriptMessageContext -ReturnCachedContext' reads that cache, so
# leaving an entry behind would keep reporting the connection this call just ended. Removing a name the
# cache does not hold does nothing, which is the case when nothing has asked for the context yet.
$ScriptMessage_Global_CachedServiceContext.PSObject.Properties.Remove([string]$Service)
$script:ScriptMessageCachedServiceContext.PSObject.Properties.Remove([string]$Service)

# Return the disconnection information, if requested.
if ($ReturnConnectionInfo)
Expand Down
29 changes: 23 additions & 6 deletions ScriptMessage/Public/Get-ScriptMessageConfig.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ function Get-ScriptMessageConfig
Optional. If not provided, the function will use the path used in the current session (if set).
.PARAMETER Service
Optional. Return only the info related to a specific service.
.PARAMETER ReturnConfigFilePath
Adds a ConfigFilePath property to the result, holding the full path of the configuration file that was read.

.EXAMPLE
Get-ScriptMessageConfig
.EXAMPLE
Get-ScriptMessageConfig -Path '.\Config\config_scriptmessage.json'
.EXAMPLE
Get-ScriptMessageConfig -Service MicrosoftGraph
.EXAMPLE
Get-ScriptMessageConfig -Service MicrosoftGraph -ReturnConfigFilePath
#>

[CmdletBinding()]
Expand All @@ -29,20 +33,24 @@ function Get-ScriptMessageConfig
Position=0,
ValueFromPipeline=$true,
ValueFromPipelineByPropertyName=$true)]
[string]$Path = $ScriptMessage_Global_ConfigFilePath, # If not entered will see if it can pull path from this variable.
[string]$Path = $script:ScriptMessageConfigFilePath, # The path set by Set-ScriptMessageConfigFilePath.

[Parameter(
Position=1,
Mandatory = $false,
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true)]
[MessagingService]$Service
[MessagingService]$Service,

[Parameter(
Mandatory = $false)]
[switch]$ReturnConfigFilePath
)

# Make Sure Requested Path Isn't Null or Empty (better to catch it here than validating on the parameter of this function)
if ([string]::IsNullOrEmpty($Path))
{
throw "`'`$ScriptMessage_Global_ConfigFilePath`' is not specified. Don't forget to first use the `'Set-ScriptMessageConfigFilePath`' cmdlet!"
throw "No ScriptMessage configuration file path is set. Run `'Set-ScriptMessageConfigFilePath`' first."
}

# Read the configuration file.
Expand Down Expand Up @@ -93,10 +101,19 @@ function Get-ScriptMessageConfig

if (-not ($null -eq $Service))
{
return $ScriptMessageConfig.$Service
$Result = $ScriptMessageConfig.$Service
}
else
{
return $ScriptMessageConfig
$Result = $ScriptMessageConfig
}

# No -Force, so a configuration file that has its own 'ConfigFilePath' setting fails here rather than having it
# silently replaced. A missing service section leaves nothing to add the path to.
if ($ReturnConfigFilePath -and ($null -ne $Result))
{
$Result | Add-Member -MemberType NoteProperty -Name 'ConfigFilePath' -Value $FullPath
}

return $Result
}
6 changes: 3 additions & 3 deletions ScriptMessage/Public/Get-ScriptMessageContext.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function Get-ScriptMessageContext
$ScriptMessageContext = New-Object System.Object

# Enable a force refresh if no data exists for the specified service or if $ReturnCachedContext is not present.
if (($null -eq $ScriptMessage_Global_CachedServiceContext.$Service) -or (-not $ReturnCachedContext.IsPresent))
if (($null -eq $script:ScriptMessageCachedServiceContext.$Service) -or (-not $ReturnCachedContext.IsPresent))
{
$RefreshContext = $true
}
Expand Down Expand Up @@ -73,11 +73,11 @@ function Get-ScriptMessageContext
}

# Update cached context data for the specified service.
$ScriptMessage_Global_CachedServiceContext | Add-Member -MemberType NoteProperty -Name $Service -Value $ScriptMessageContext -Force # Force allows overwriting existing members.
$script:ScriptMessageCachedServiceContext | Add-Member -MemberType NoteProperty -Name $Service -Value $ScriptMessageContext -Force # Force allows overwriting existing members.
}
else
{
$ScriptMessageContext = $ScriptMessage_Global_CachedServiceContext.$Service
$ScriptMessageContext = $script:ScriptMessageCachedServiceContext.$Service
}

return $ScriptMessageContext
Expand Down
2 changes: 1 addition & 1 deletion ScriptMessage/Public/Set-ScriptMessageConfigFilePath.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ function Set-ScriptMessageConfigFilePath
[string]$Path
)

New-Variable -Name 'ScriptMessage_Global_ConfigFilePath' -Value $Path -Scope Global -Force
$script:ScriptMessageConfigFilePath = $Path
}
2 changes: 1 addition & 1 deletion ScriptMessage/ScriptMessage.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ FunctionsToExport = @(
CmdletsToExport = @()

# Variables to export from this module
VariablesToExport = '*'
VariablesToExport = @()

# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export.
AliasesToExport = @()
Expand Down
14 changes: 9 additions & 5 deletions ScriptMessage/ScriptMessage.psm1
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
# Global Variables
New-Variable -Name 'ScriptMessage_Global_CachedServiceContext' -Value ([PSCustomObject]@{}) -Scope Global -Force

# Module Variables
# Each file in Services/ registers itself here as it is dot-sourced below. Module scope rather than global:
# nothing outside the module reads it, and it is rebuilt from scratch on every import.
# Module scope rather than global, so callers can neither see nor change them, and each import starts them fresh.

# The configuration file path set by Set-ScriptMessageConfigFilePath, read by Get-ScriptMessageConfig.
$script:ScriptMessageConfigFilePath = $null

# Each service's context from Get-ScriptMessageContext. Disconnect-ScriptMessage removes a service's entry.
$script:ScriptMessageCachedServiceContext = [PSCustomObject]@{}

# Each file in Services/ registers itself here as it is dot-sourced below.
$script:ScriptMessageServiceTable = [ordered]@{}

# Aliases
Expand Down
58 changes: 58 additions & 0 deletions Tests/Get-ScriptMessageConfig.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,64 @@ Describe 'Get-ScriptMessageConfig paths' {
}
}

Describe 'Get-ScriptMessageConfig and the stored path' {
BeforeAll {
$AsciiName = 'Stored path test'
}

# Importing the module again clears the path, so each test starts with none set.
BeforeEach {
Import-Module (Join-Path $PSScriptRoot '..\ScriptMessage\ScriptMessage.psd1') -Force
}

It 'reads the file set by Set-ScriptMessageConfigFilePath' {
$Path = Join-Path $TestDrive 'stored.json'
New-TestConfigFile -Path $Path -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false))
Set-ScriptMessageConfigFilePath -Path $Path

(Get-ScriptMessageConfig -Service MicrosoftGraph).MgApp_CertificateName | Should -BeExactly $AsciiName
}

It 'reports that no path is set' {
{ Get-ScriptMessageConfig } | Should -Throw -ExpectedMessage 'No ScriptMessage configuration file path is set*'
}

It 'adds the full path of the file it read with -ReturnConfigFilePath' {
New-TestConfigFile -Path (Join-Path $TestDrive 'relative.json') -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false))

Push-Location -LiteralPath $TestDrive
try
{
Set-ScriptMessageConfigFilePath -Path '.\relative.json'
$Config = Get-ScriptMessageConfig -ReturnConfigFilePath
}
finally
{
Pop-Location
}

$Config.MicrosoftGraph.MgApp_CertificateName | Should -BeExactly $AsciiName
$Config.ConfigFilePath | Should -Be (Join-Path $TestDrive 'relative.json')
}

It 'adds the path to the service section returned for -Service' {
$Path = Join-Path $TestDrive 'service.json'
New-TestConfigFile -Path $Path -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false))

$Config = Get-ScriptMessageConfig -Path $Path -Service MicrosoftGraph -ReturnConfigFilePath

$Config.MgApp_CertificateName | Should -BeExactly $AsciiName
$Config.ConfigFilePath | Should -Be $Path
}

It 'adds no path without -ReturnConfigFilePath' {
$Path = Join-Path $TestDrive 'no-switch.json'
New-TestConfigFile -Path $Path -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false))

(Get-ScriptMessageConfig -Path $Path).PSObject.Properties.Name | Should -Not -Contain 'ConfigFilePath'
}
}

Describe 'Get-ScriptMessageConfig errors' {
It 'reports a folder that does not exist' {
{ Get-ScriptMessageConfig -Path (Join-Path $TestDrive 'No Such Folder\config.json') } |
Expand Down
Loading
Loading