diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4371f3c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,54 @@ +# Line-ending and diff rules for this repository. +# +# Goal: the module must load and behave identically under Windows PowerShell 5.1 (Windows only) and +# PowerShell 7.x+ on Windows, Linux, and macOS, no matter what each contributor's core.autocrlf is set to. +# +# Line endings do not affect how PowerShell parses a script (every supported host reads LF and CRLF +# equally), so these rules exist to keep checkouts byte-identical and diffs clean rather than to make the +# code work. Git stores text files with LF internally and applies the eol rules below on checkout. +# +# Every text type is pinned to LF, which is what Git already stores, so a checked-out file is byte for byte +# the repository's copy on every platform and whatever a contributor's core.autocrlf says. That leaves no +# conversion layer to reason about, which matters because Git hides line-ending differences: it normalizes +# before diffing, so a file whose endings are wrong shows no diff and leaves "git status" clean. +# +# CRLF would keep checkouts equally consistent and Windows PowerShell 5.1 reads either, but it reintroduces +# that conversion layer, and every tool here that is not PowerShell (grep, sed, diff, CI, the GitHub web +# view) prefers LF. + +# Let Git detect text files and normalize them, unless overridden below. +* text=auto eol=lf + +# PowerShell sources. +*.ps1 text eol=lf +*.psm1 text eol=lf +*.psd1 text eol=lf +*.ps1xml text eol=lf +*.psrc text eol=lf +*.pssc text eol=lf + +# Docs, config, and data. Pinned too, so no text file in the tree depends on core.autocrlf. +*.md text eol=lf +*.txt text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.csv text eol=lf +LICENSE text eol=lf + +# Binaries. These must never be line-ending converted or treated as text. Git's auto-detection normally +# catches them, but stating it removes the guesswork. +*.dll binary +*.exe binary +*.nupkg binary +*.zip binary +*.docx binary +*.xlsx binary +*.pdf binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pfx binary +*.cer binary diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..c3b250c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## Summary + + + +## Release classification + + + +- [ ] Major: breaks the public contract (an existing script or configuration file can stop working) +- [ ] Minor: adds backward-compatible functionality +- [ ] Patch: a backward-compatible fix +- [ ] None: no user-visible change (tests, CI, or contributor documentation only) + +## Checklist + +- [ ] Targets `develop`, or `main` for a hotfix +- [ ] `CHANGELOG.md` has an entry in the right section under `[Unreleased]` (added if missing), or the change is not user-visible +- [ ] `.\Tests\Invoke-Tests.ps1` passes +- [ ] Contains no real tenant names, email domains, secrets, or configuration files +- [ ] Any live Microsoft Graph testing was done in a test tenant and is described above diff --git a/.github/workflows/PSGallery.yml b/.github/workflows/PSGallery.yml deleted file mode 100644 index 4b49042..0000000 --- a/.github/workflows/PSGallery.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: PSGallery - -# Controls when the workflow will run -on: - # Triggers the workflow on new version release - release: - types: [published] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# A workflow run is made up of one or more jobs that can run sequentially or in parallel -jobs: - psgallery_publish: - # The type of runner that the job will run on - runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v4 - - - name: Publish - run: | - Publish-Module -Path 'ScriptMessage' -NuGetApiKey ${{ secrets.PS_GALLERY_KEY }} - shell: pwsh diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml new file mode 100644 index 0000000..5f2fc86 --- /dev/null +++ b/.github/workflows/Release.yml @@ -0,0 +1,177 @@ +name: Release + +# Publishes a release when a maintainer pushes a version tag, such as 1.2.0 or 2.0.0-rc01. RELEASING.md describes +# the whole process. The job order matters: nothing becomes public until the checks and tests pass, and the +# GitHub release is created only after the PowerShell Gallery accepts the package, so a failed publish never +# leaves behind a release for a version that did not ship. +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+' + - '[0-9]+.[0-9]+.[0-9]+-*' + +permissions: + contents: read + +# One run per tag, so two runs can never publish the same version at once. +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + validate: + name: Validate the release tag + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + # Full history, to confirm the tagged commit is on main (or on develop, for a prerelease). + fetch-depth: 0 + + - name: Check the tag against the manifest, changelog, and branch + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + $Tag = $env:TAG + + # Release tags must be annotated; a lightweight tag has no tagger, date, or message. + $TagObjectType = gh api "repos/$env:GITHUB_REPOSITORY/git/ref/tags/$Tag" --jq '.object.type' + if ($LASTEXITCODE -ne 0) { throw "Could not look up tag $Tag." } + if ($TagObjectType -ne 'tag') { throw "Tag $Tag is a lightweight tag. Release tags must be annotated (git tag -a)." } + + # The tag must match the manifest version, including any prerelease label. + $Manifest = Import-PowerShellDataFile -Path 'ScriptMessage/ScriptMessage.psd1' + $Prerelease = $Manifest.PrivateData.PSData.Prerelease + $ManifestVersion = if ($Prerelease) { "$($Manifest.ModuleVersion)-$Prerelease" } else { "$($Manifest.ModuleVersion)" } + if ($ManifestVersion -ne $Tag) { throw "Tag $Tag does not match the manifest version $ManifestVersion." } + + # The changelog must have a dated section for this version. + $HeadingPattern = '^## \[' + [regex]::Escape($Tag) + '\](\([^)]*\))? - \d{4}-\d{2}-\d{2}\s*$' + if (-not (Select-String -Path 'CHANGELOG.md' -Pattern $HeadingPattern -Quiet)) { throw "CHANGELOG.md has no dated section for $Tag, such as '## [$Tag] - 2026-01-31'." } + + # [Unreleased] exists only on develop, so a released CHANGELOG.md never carries one. + if (Select-String -Path 'CHANGELOG.md' -Pattern '^## \[Unreleased\]' -Quiet) { throw "CHANGELOG.md still has an [Unreleased] section. The release-prep pull request renames it to the $Tag heading and adds no new one." } + + # Stable releases come only from main; prereleases may also come from develop. + $AllowedBranches = if ($Prerelease) { @('origin/main', 'origin/develop') } else { @('origin/main') } + $OnAllowedBranch = $false + foreach ($Branch in $AllowedBranches) { + git merge-base --is-ancestor $env:GITHUB_SHA $Branch + if ($LASTEXITCODE -eq 0) { $OnAllowedBranch = $true } + } + if (-not $OnAllowedBranch) { throw "Tag $Tag points at $env:GITHUB_SHA, which is not on $($AllowedBranches -join ' or ')." } + + "Tag $Tag is annotated, matches the manifest and changelog, and is on $($AllowedBranches -join ' or ')." + # git merge-base leaves a non-zero exit code when the commit is not on one of the branches checked. + exit 0 + + test: + name: Tests + needs: validate + uses: ./.github/workflows/Tests.yml + + publish: + name: Publish to the PowerShell Gallery + needs: test + runs-on: windows-latest + # The PowerShell Gallery API key is stored only in this environment, which can require a maintainer's approval. + environment: psgallery + + steps: + - uses: actions/checkout@v7 + + - name: Build the package + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Force -Path 'out' | Out-Null + Compress-PSResource -Path './ScriptMessage' -DestinationPath './out' + $Packages = @(Get-ChildItem -Path './out' -Filter '*.nupkg') + if ($Packages.Count -ne 1) { throw "Expected one package, found $($Packages.Count)." } + "Built $($Packages[0].Name) ($($Packages[0].Length) bytes)." + + - name: Publish the package + shell: pwsh + env: + PS_GALLERY_KEY: ${{ secrets.PS_GALLERY_KEY }} + run: | + $ErrorActionPreference = 'Stop' + $Package = Get-ChildItem -Path './out' -Filter '*.nupkg' | Select-Object -First 1 + Publish-PSResource -NupkgPath $Package.FullName -Repository 'PSGallery' -ApiKey $env:PS_GALLERY_KEY + + # The GitHub release job attaches this exact package instead of building its own. + - name: Keep the package for the GitHub release + uses: actions/upload-artifact@v7 + with: + name: package + path: out/*.nupkg + if-no-files-found: error + + github_release: + name: Create the GitHub release + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v7 + + - name: Get the published package + uses: actions/download-artifact@v8 + with: + name: package + path: out + + - name: Create and publish the release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + $Tag = $env:TAG + + # The release notes are this version's CHANGELOG.md section, without its heading or trailing separator. + $Lines = @(Get-Content -Path 'CHANGELOG.md') + $HeadingPattern = '^## \[' + [regex]::Escape($Tag) + '\]' + $HeadingIndex = -1 + for ($Index = 0; $Index -lt $Lines.Count; $Index++) { + if ($Lines[$Index] -match $HeadingPattern) { $HeadingIndex = $Index; break } + } + if ($HeadingIndex -lt 0) { throw "CHANGELOG.md has no section for $Tag." } + $SectionLines = @() + if ($HeadingIndex -lt ($Lines.Count - 1)) { + $SectionLines = foreach ($Line in $Lines[($HeadingIndex + 1)..($Lines.Count - 1)]) { + if ($Line -match '^## \[') { break } + $Line + } + } + $Notes = ((@($SectionLines) -join "`n").Trim() -replace '\s*-{3,}\s*$', '').Trim() + Set-Content -Path 'release-notes.md' -Value $Notes -Encoding utf8 + + # A re-run of this job finds the release already created. Only a draft still needs its package and publishing. + $Existing = gh release view $Tag --json isDraft 2>$null + if ($LASTEXITCODE -ne 0) { + $CreateArguments = @('release', 'create', $Tag, '--verify-tag', '--draft', '--title', $Tag, '--notes-file', 'release-notes.md') + if ($Tag.Contains('-')) { $CreateArguments += '--prerelease' } + gh @CreateArguments + if ($LASTEXITCODE -ne 0) { throw "Could not create the release for $Tag." } + } + elseif (-not ($Existing | ConvertFrom-Json).isDraft) { + "The release for $Tag is already published." + exit 0 + } + + foreach ($Package in Get-ChildItem -Path 'out' -Filter '*.nupkg') { + gh release upload $Tag $Package.FullName --clobber + if ($LASTEXITCODE -ne 0) { throw "Could not attach $($Package.Name) to the release for $Tag." } + } + + gh release edit $Tag --draft=false + if ($LASTEXITCODE -ne 0) { throw "Could not publish the release for $Tag." } + exit 0 diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml new file mode 100644 index 0000000..280109c --- /dev/null +++ b/.github/workflows/Tests.yml @@ -0,0 +1,50 @@ +name: Tests + +# Runs the Pester suite under Windows PowerShell 5.1 and PowerShell 7, and lints the workflow files. Nothing here +# connects to a tenant or needs credentials. +on: + # Branch pushes only. A release tag runs this suite through the Release workflow instead. + push: + branches: ['**'] + pull_request: + # Lets the Release workflow gate publishing on this same suite instead of repeating its steps. + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + # Catches what a YAML parser cannot: a job that needs one that does not exist, a reusable-workflow path that + # resolves to nothing, an unknown runner label, a retired action version. Pinned to a version rather than + # :latest so a workflow that passes today does not start failing on someone else's release. + lint: + name: Workflow lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Check workflow files + uses: docker://rhysd/actionlint:1.7.12 + with: + args: -color + + pester: + name: Pester + # Windows, because Windows PowerShell 5.1 exists nowhere else. Invoke-Tests.ps1 runs both editions itself. + runs-on: windows-latest + + steps: + - uses: actions/checkout@v7 + + # The module tests run PSScriptAnalyzer when it is installed. Pinned so a new analyzer rule cannot fail a + # build that passed before. + - name: Install PSScriptAnalyzer + run: Install-PSResource -Name PSScriptAnalyzer -Version 1.25.0 -TrustRepository + shell: pwsh + + - name: Run tests + # -UsePinnedPester so both editions run the same Pester version, whatever the runner has installed. + run: .\Tests\Invoke-Tests.ps1 -UsePinnedPester + shell: pwsh diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5dc8a3e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,236 @@ +# Guidance for AI Assistants Working in This Repository + +This is the single source of truth for AI assistant guidance. `CLAUDE.md` at the repository root is a pointer +to this file, so Claude Code and Codex both pick up the same rules. Edit this file, not the pointer. + +**What this repository is:** `ScriptMessage`, a PowerShell module that lets scripts send email and chat +messages through messaging services (currently Microsoft Graph) with one set of cmdlets. Public functions live +one per file in `ScriptMessage/Public/`, private helpers in `ScriptMessage/Private/`, and each messaging +service's implementation in `ScriptMessage/Services/`; `ScriptMessage/ScriptMessage.psm1` dot-sources all +three folders. Work happens on `develop`; `main` is the released branch. The GitHub wiki is a separate +repository (`Sekers/ScriptMessage.wiki`), not part of this one. + +## Safety rules that override convenience + +- **Never send a real message without explicit permission.** Sending is this module's whole job, so running + `Send-ScriptMessage` against a real configuration delivers real email or Teams chat to real people, and a + chat with attachments also uploads the files to the sender's OneDrive and shares them with the recipients. + `Connect-ScriptMessage` can open an interactive sign-in, so ask before running that too. The scripts in + `Tests/` use whichever configuration their `Set-ScriptMessageConfigFilePath` line names; read that line, and + the recipients, before running anything. +- **Never name the production tenant in any file.** No organization name, abbreviation, or email domain in + code, comments, help examples, tests, or commit messages. Use placeholder addresses on `domain.com`, as the + existing help examples do. +- **Never commit a real configuration file.** Configuration files hold the tenant ID, client ID, certificate + details, and encrypted secrets. `Tests/Config/` and `@Local Only/` are gitignored for that reason, and + `Templates/config_scriptmessage.json` must only ever contain placeholder values. + +## CHANGELOG.md + +**The changelog is the release notes.** Its entries are what users actually read when a version ships, so it +is the destination for user-facing change, not a staging area for it. Never move that content into +`README.md`. If something genuinely needs more room than an entry allows, it goes in the wiki, and only when +it is a big enough issue that people need to be warned about it. + +### The baseline is the latest RELEASE, never the development branch + +An entry describes what changed for someone **upgrading from the last released version**. Before writing any +"Fixed ..." entry, verify the bug actually existed in that release. + +**Derive the release. Do not trust a version number written down anywhere, including here or in +`ScriptMessage/ScriptMessage.psd1`.** A release is an annotated tag; pushing it runs +`.github/workflows/Release.yml`, which publishes that build to the PowerShell Gallery and then creates the GitHub +release ([RELEASING.md](./RELEASING.md) describes the whole process and the versioning rules). `origin/main` can +sit past the last tag (1.1.0 got a changelog-only commit after its tag, for example), so ask for the nearest tag +rather than an exact match: + +```powershell +git fetch origin --tags --quiet # only if the local copy might be behind +$Release = git describe --tags --abbrev=0 origin/main +``` + +Work happens on `develop`. The **local `main` branch is stale**, so compare against `$Release` or +`origin/main`, never local `main`, and never against `HEAD` or `develop`. + +Verify before claiming a fix: + +```powershell +git cat-file -e "${Release}:ScriptMessage/Public/.ps1" # nonzero exit = did not exist in the release +git show "${Release}:ScriptMessage/Public/.ps1" | Select-String '' # was the bug there? +git show "${Release}:ScriptMessage/Services/MicrosoftGraph.ps1" | Select-String '' +git tag --contains # no output = the commit that introduced the bug never shipped +``` + +**A bug introduced on `develop` and fixed before release is not a changelog entry.** It never reached a user. +This is easy to get wrong while a release is in progress, because a lot of churn happens on `develop`, and a +fix to something that itself landed after the last tag is invisible to users. The checks above settle it. + +**A function that does not exist in the last release** belongs under **Added** as a new function only. It can +never also appear as a "Fixed" entry, and it should not be listed among the functions a fix "affects." The same +goes for a new configuration setting or message type. + +**A change the module adapted to is not a fix.** "Fixed" claims a defect in this module. If the code was never +wrong and something underneath it changed (the Microsoft Graph API, the Microsoft Graph PowerShell SDK, or +PowerShell itself), the entry belongs in **Changed**, or in **Added** when it gives callers something new. The +giveaway is that the change in behavior already reached users on the last release without any change in this +repository. + +### Write for the end user, never for the module developer + +An entry answers one question: **what changes for someone using this module?** Anything that does not help a +caller decide "does this affect me, and do I need to do something?" belongs in the commit message or a code +comment, not here. + +Include: + +- What went wrong from the caller's side, described in terms of what they saw. +- What the behavior is now. +- The public functions, parameters, and configuration file settings affected, and any action the user has to + take, such as editing their configuration file or installing another Microsoft Graph sub-module. + +**Verify the symptom, not just the bug.** A real defect still produces a false entry if the impact is +overstated. Trace what a caller actually experienced before describing it. For example, `Send-ScriptMessage` +catches errors raised while a service sends and reports them in the returned result's `Error` property instead +of throwing, so a bug that broke sending usually showed up to the caller as an `Error` entry, not a crash. + +Leave out: + +- **Root cause and mechanism.** Why the bug happened is developer information. "A `-not` check treated the + enum value `OneOnOne` as false" tells a user nothing they can act on; "`-ChatType OneOnOne` was ignored when + the configuration file set `ChatType` to `Group`" does. +- **Private helpers, internal variables and code paths.** Only name things a caller can actually use; check + `FunctionsToExport` in `ScriptMessage/ScriptMessage.psd1` before naming a function. Service functions such + as `Send-ScriptMessage_MicrosoftGraph` are private. +- **How it was found, measured or diagnosed**, including pointers to `Research_Notes/`. Those notes are for + contributors, so link them from the commit message or the code, not from a user-facing entry. +- **Anything with no observable effect on a caller.** If no script a user could reasonably write would have + hit the bug, there is nothing to log. + +Two or three sentences is a normal entry. Length is not thoroughness: someone scanning a release to see +whether it affects them should get the answer in the first line. + +**One entry, one audience.** If a single change produces two facts aimed at different readers, write two +entries. A fix for delegated permissions alongside a new requirement for application permissions reads as a +contradiction in one bullet; as two bullets, each reader finds their half immediately. + +### Structure and style + +The format is [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). [RELEASING.md](./RELEASING.md) +covers when `[Unreleased]` becomes a version and how that version is chosen. + +- **New entries go under `## [Unreleased]`** at the top of the file. Never invent a version number for them; + the release-prep pull request turns `[Unreleased]` into a dated version heading. +- **Add the `[Unreleased]` section only with an entry.** Right after a release there is none, so the first new + entry also adds `## [Unreleased](https://github.com/Sekers/ScriptMessage/compare/...develop)` at the + top, where `` 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: + + ```markdown + ## [1.2.0](https://github.com/Sekers/ScriptMessage/tree/1.2.0) - 2026-09-20 + + ### Fixed + + - ... + ``` + +- Sections in order: `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`, `### Security`. + Omit a section that has no entries. +- Versions 1.1.0 and earlier use `### Fixes`, `### Features`, and `### Other`, with an author line. Leave them + as they are. +- 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**. +- A new public function goes under **Added** and reads `- New Function: > `. +- **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. +- In new entries, put code identifiers in backticks in Markdown, including parameter names, configuration + settings, and .NET property names, so a bare `Type` does not read as ordinary prose. Older entries quote + identifiers with single quotes; leave them as they are. In PowerShell comment-based help, do **not** use + backticks; `Get-Help` renders them literally. + +## File encoding and line endings + +**This is already decided. Do not re-derive it, and do not "tidy" files to match a different opinion.** +`.gitattributes` is the authority and explains its own reasoning; this section exists so you know the answer +without going to look. + +- **LF everywhere.** Every text type is pinned `eol=lf`, which is what Git stores anyway, so a checked-out + file is byte for byte the repository's copy on any platform and under any `core.autocrlf`. +- **UTF-8 with no byte order mark.** Every tracked text file is UTF-8 without a BOM. +- **PowerShell files contain only ASCII.** This covers `.ps1`, `.psm1`, `.psd1`, and `.ps1xml` files. Windows + PowerShell 5.1 reads a PowerShell file that has no BOM using the system's legacy code page, so a single + non-ASCII character (a curly quote or an em dash pasted into a message string, for example) is silently + misread there while working fine in PowerShell 7. In code, write a non-ASCII character as an escape such as + `[char]0x00E9`. A `.psd1` cannot contain expressions, so if a module manifest genuinely needs a non-ASCII + character, save that one file as UTF-8 with a BOM, which both editions read correctly + (`Research_Notes/File-Encoding-And-Line-Endings.md` section 1). +- **Other text files may contain non-ASCII characters**, such as a contributor's name in `CHANGELOG.md`. The + no-em-dash rule in the changelog's "Structure and style" section still applies to every file. +- **The module writes no files at runtime.** It only reads the configuration file, which users create + themselves. If a feature ever writes a file, choose its encoding deliberately: `Out-File -Encoding utf8` + writes a BOM on Windows PowerShell 5.1 and none on PowerShell 7, and 5.1 has no `utf8NoBOM`. +- **Skip anything containing a NUL byte**, which is Git's own binary test. Git reports such a file as + `-text`, so no line-ending rule applies to it, and a bulk "read text, write text" pass over one re-encodes + it and corrupts it. + +Git will not show you a line-ending mistake: it normalizes before diffing, so a wrong file produces no diff +and leaves `git status` clean. Use this instead, which wants `w/lf` on every row: + +```powershell +git ls-files --eol -- '*.ps1' '*.psm1' '*.psd1' '*.md' '*.json' '*.yml' +``` + +Nothing enforces the BOM and ASCII rules automatically yet, so check them before committing. Run this from the +repository root; it prints nothing when every tracked file passes. A module manifest saved with a BOM on purpose +is reported, and is the one expected exception: + +```powershell +git ls-files | ForEach-Object { + $Path = Join-Path $PWD.ProviderPath $_ # .NET does not follow PowerShell's current location + $Bytes = [System.IO.File]::ReadAllBytes($Path) + if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) { "BOM: $_" } + if (($_ -match '\.(ps1|psm1|psd1|ps1xml)$') -and ([System.IO.File]::ReadAllText($Path) -match '[^\x00-\x7F]')) { "Non-ASCII: $_" } +} +``` + +New files can pick up CRLF on Windows (VS Code's default line ending follows the operating system), so check +a file you created with the `git ls-files --eol` command above once it is tracked. Never change the line +endings of a file you are not otherwise editing. To fix one that is wrong: + +```powershell +$Path = (Resolve-Path -LiteralPath '').ProviderPath # .NET needs a full path +$Text = [System.IO.File]::ReadAllText($Path) +[System.IO.File]::WriteAllText($Path, ($Text -replace "`r`n","`n")) # writes UTF-8 without a BOM +``` + +## Code + +- **A new public function needs an explicit entry in `FunctionsToExport` in + `ScriptMessage/ScriptMessage.psd1`.** Every `ScriptMessage/Public/*.ps1` is dot-sourced automatically, so a + function with no manifest entry loads but stays invisible to callers. Aliases work the same way through + `AliasesToExport`. +- **Comments describe the code as it is now**, never what it used to do or what a fix changed. Change history + belongs in the commit message and the changelog. + +## Research notes + +Researched or measured behavior belongs in `Research_Notes/`, one file per behavior category (PowerShell +language behavior, Microsoft Graph behavior, file encoding, and so on); list the directory to see what already +exists. Add to the matching file, or create a new file for a new category rather than stretching an existing +one. + +- **Label every claim with its evidence:** **Measured**, **From source** (read from a file in this + repository), **From documentation** (with the page linked), or **Unverified**. An unverified assumption the + module depends on is still worth recording, labelled as such. +- **Date everything that can change.** Give the date and environment (PowerShell editions and versions, module + versions) for anything measured, and the date read for anything taken from documentation. +- **Say what a result does not establish**, so a later reader does not stretch it past what was tested. +- **Tie each behavior to the code it affects**, by file and function name rather than line number. +- **The notes are for contributors.** Link them from commit messages and code comments, never from + `CHANGELOG.md` or other user-facing text. +- **The safety rules above apply while gathering evidence:** no live sends without permission, and no tenant + name or email domain in a note. diff --git a/CHANGELOG.md b/CHANGELOG.md index 003e880..60882dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog for ScriptMessage PowerShell Module +## [1.1.1](https://github.com/Sekers/ScriptMessage/tree/1.1.1) - 2026-09-16 + +### Changed + +- The sample configuration file `Templates/config_scriptmessage.json` now includes the `MgDelegatedPermission_RequestFilesReadWritePermission` setting, which requests permission to upload Teams chat attachments to OneDrive when using delegated permissions. Earlier versions already read this setting, so it can be added to an existing configuration file. + +### Fixed + +- `Send-ScriptMessage` no longer rejects recipients given as a one-item array holding a recipient object, such as a single `Name` and `Address` entry read from a JSON configuration file. When those were the only recipients, it failed with "Please provide at least one parameter value for any of the following: To, CC, or BCC" and sent nothing. +- `Send-ScriptMessage` now sends Teams chat messages that have no attachments. A call using `-Type Chat` delivered nothing and returned "Cannot index into a null array" in the result's `Error` property, unless the same call also sent mail or the chat included an attachment. Both `OneOnOne` and `Group` chats were affected. +- On Windows PowerShell 5.1, `Send-ScriptMessage` no longer fails to send Teams chat messages that have an attachment. Such a send could deliver nothing and report "Unable to find type [System.Web.HttpUtility]" in the result's `Error` property. +- In both PowerShell editions, `Send-ScriptMessage` now escapes spaces in chat attachment filenames correctly when uploading them. A filename containing a space was uploaded to a path holding a plus sign in place of each space. +- Minor: `Send-ScriptMessage` now stops with that same error when the only recipients have blank addresses, such as an object or hashtable whose `Address` is empty, or a string of only spaces. +- Minor: `Connect-ScriptMessage` and `Send-ScriptMessage` no longer require the `Microsoft.Graph.Files` module when `MgPermissionType` is `Application`. If `MgDelegatedPermission_RequestFilesReadWritePermission` was also `true` and the module was not installed, they failed with "Please first install the following sub-modules" and nothing was sent. With delegated permissions, that setting still requires the module. +- Minor: With `MgApp_AuthenticationType` set to `CertificateFile`, the error reported when the certificate needs a password and `MgApp_EncryptedCertificatePassword` is empty now names Microsoft Graph. +- Minor: On Windows PowerShell 5.1, a configuration file saved as UTF-8 without a byte order mark is now read correctly when it contains non-ASCII characters, such as an accented letter in `MgApp_CertificateName`. `Get-ScriptMessageConfig`, `Connect-ScriptMessage`, and `Send-ScriptMessage` silently read those characters as different ones. This is how PowerShell 7's `Set-Content` and `Out-File` save a file. +- Minor: On PowerShell 7, a configuration file saved in the Windows ANSI code page is now read correctly when it contains non-ASCII characters. `Get-ScriptMessageConfig`, `Connect-ScriptMessage`, and `Send-ScriptMessage` silently read those characters as replacement characters. This is how Windows PowerShell 5.1's `Set-Content` saves a file. +- 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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f6cf206 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# Guidance for AI assistants + +This repository keeps its AI guidance in `AGENTS.md` at the repository root, so that multiple AI agents +share one source of truth. Read that file before working here, and make any edits there rather than in this +pointer. + +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a9a6c5a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Contributing to ScriptMessage + +Thank you for helping improve ScriptMessage. This guide covers the everyday workflow. +[RELEASING.md](./RELEASING.md) covers versioning, branches, and releases in full. + +## Workflow + +1. Fork the repository, or create a branch in it if you have write access. +2. Branch from `develop` as `feature/`. Urgent fixes to the released version go on a + `hotfix/` branch from `main` instead; check with a maintainer first. +3. Make your change, following the conventions of the surrounding code. +4. Run the tests under both PowerShell editions (Windows PowerShell 5.1 and PowerShell 7): + + ```powershell + .\Tests\Invoke-Tests.ps1 + ``` + +5. If users will notice the change, add an entry to `CHANGELOG.md` under `[Unreleased]`. If that section is not + there yet, as right after a release, add it at the top ([RELEASING.md](./RELEASING.md#changelog) shows the + heading). +6. Open a pull request into `develop` (or into `main` for a hotfix) and complete the pull request template, + including the release classification. + +## Rules + +- **Never commit a real configuration file, secret, or certificate.** Never name a real organization, tenant, or + email domain in code, tests, examples, or commit messages; use `domain.com` addresses instead. +- **The automated tests never connect to a tenant.** If a change needs a live Microsoft Graph test, run it in + your own test tenant and describe what you tested in the pull request. +- **Text files are UTF-8 with LF line endings and no byte order mark, and PowerShell files contain only ASCII.** + Windows PowerShell 5.1 misreads non-ASCII characters in PowerShell files saved without a byte order mark. + `.gitattributes` handles line endings, and [AGENTS.md](./AGENTS.md) explains the rules and how to check them. +- **Rebase only your own branch, and only before it merges.** Never force-push `develop` or `main`. + +AI assistants working in this repository follow [AGENTS.md](./AGENTS.md), which also holds the detailed rules +for writing changelog entries. diff --git a/README.md b/README.md index fef1de9..c1bab01 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Table of Contents -- [This Module is in Public Preview](#this-module-is-in-public-preview) +- [Versioning](#versioning) - [Overview](#overview) - [Supported Services](#supported-services) - [What's New](#whats-new) @@ -10,8 +10,8 @@ - [Developing and Contributing](#developing-and-contributing) --- -## This Module is in Public Preview -This module can be used in production environments. However, since it is in public preview and certain functions or cmdlet responses may change, please check for any breaking changes in the [CHANGELOG.md](./CHANGELOG.md) before updating to a newer version. +## Versioning +This module can be used in production environments. Releases after 1.1.0 follow [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html): breaking changes increase the major version, new backward-compatible functionality increases the minor version, and backward-compatible fixes increase the patch version. Check [CHANGELOG.md](./CHANGELOG.md) before updating to a new major version, and see [RELEASING.md](./RELEASING.md) for the full versioning and release policy. ## Overview @@ -28,6 +28,11 @@ ScriptMessage is designed to simplify the use of messaging services in PowerShel - [**Microsoft Graph SDK PowerShell:**](https://learn.microsoft.com/en-us/powershell/microsoftgraph/overview?view=graph-powershell-1.0) Take advantage of the Microsoft Graph SDK PowerShell module to send email and chat messages using the Graph API without having to learn all the object formatting that the API requires (which unfortunately the SDK doesn't simplify). - Since the Microsoft Graph API only supports Teams Chat when using delegated [permissions](https://learn.microsoft.com/en-us/graph/permissions-overview), we are looking into [Teams Bots](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/overview) support for future releases to allow for application permissions. - Currently, the ScriptMessage module only supports one-on-one and group chats. Teams Channel chats will be enabled in a future release. + - Specific Modules Used (if you want to minimize footprint): + - Microsoft.Graph.Authentication (Mail & Chat) + - Microsoft.Graph.Users.Actions (Mail Only) + - Microsoft.Graph.Teams (Chat Only) + - Microsoft.Graph.Files (Mail & Chat - Only if OneDrive Uploads are Needed) - [**Mailozaurr:**](https://github.com/EvotecIT/MailoZaurr) Support is planned in future releases. Mailozaurr is a PowerShell module that aims to provide SMTP, POP3, IMAP and few other ways to interact with Email. Underneath it uses MimeKit and MailKit and EmailValidation libraries. - [**Slack:**](https://api.slack.com/) Support is planned in future releases. Send messages into channels ([including ephemeral messages](https://api.slack.com/surfaces/messages#ephemeral)) or directly to users. - [**PSGSuite:**](https://github.com/SCRT-HQ/PSGSuite) Support is planned in future releases. Send Google Workspace Gmail & Chat messages. PSGSuite is a PowerShell module wrapping Google's .NET SDKs. @@ -48,6 +53,6 @@ The ScriptMessage module documentation is hosted in the [ScriptMessage Wiki](htt ## Developing and Contributing -This project is developed using a [simplified Gitflow workflow](https://www.grimadmin.com/article.php/simple-modified-gitflow-workflow) that cuts out the release branches, which are unnecessary when maintaining only a single version for production. The Master/Main branch will always be the latest stable version released and tagged with an updated version number anytime the Develop branch is merged into it. [Rebasing](https://www.atlassian.com/git/tutorials/merging-vs-rebasing) will occur if we need to streamline complex history. +This project is developed using a [simplified Gitflow workflow](https://www.grimadmin.com/article.php/simple-modified-gitflow-workflow) that cuts out the release branches, which are unnecessary when maintaining only a single version for production. The Main branch is always the latest stable version released, and it is tagged with the new version number each time the Develop branch is merged into it. Shared branches are never rebased or force-pushed. See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to contribute, and [RELEASING.md](./RELEASING.md) for branching, versioning, and releases. You are welcome to [fork](https://guides.github.com/activities/forking/) the project and then offer your changes back using a [pull request](https://guides.github.com/activities/forking/#making-a-pull-request). \ No newline at end of file diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..0c252a2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,198 @@ +# Releasing ScriptMessage + +This is the release policy for ScriptMessage: how versions are numbered, how branches are used, how the +changelog is kept, and how a release is published. It applies to every maintainer and contributor. +[CONTRIBUTING.md](./CONTRIBUTING.md) covers everyday contributions, and the `CHANGELOG.md` section of +[AGENTS.md](./AGENTS.md) holds the detailed rules for writing changelog entries. + +## Versioning + +ScriptMessage follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) for every release after +1.1.0. Earlier releases did not follow it strictly (some patch releases contained breaking changes), and their +numbers stay as they are. + +| Change to the public contract | Increment | Example | +|---|---|---| +| Breaking: an existing script or configuration file can stop working | Major | `1.4.2` to `2.0.0` | +| New backward-compatible functionality, including a new optional parameter | Minor | `1.4.2` to `1.5.0` | +| Backward-compatible fix | Patch | `1.4.2` to `1.4.3` | + +The PowerShell Gallery's own publishing guidelines give new parameters as a patch-level example. ScriptMessage +uses the stricter Semantic Versioning rule: new functionality, including a new parameter, is a minor release. + +### The public contract + +The public contract is anything a caller can observe or depend on: + +- Exported functions and aliases, their parameters and parameter sets, and each parameter's requirements, + defaults, validation, and meaning. +- Pipeline input and binding. +- Output objects, including the properties of the `Send-ScriptMessage` result. +- Errors, and whether they terminate. +- Supported PowerShell editions and versions (Windows PowerShell 5.1 and PowerShell 7) and supported operating + systems. +- Configuration file settings, their meaning, and their defaults. +- 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. + +### 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. +- **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, + and a `Deprecated` changelog entry), and remove it no earlier than the next major release. +- **If a breaking change ships in a minor or patch release by mistake,** do not replace or unlist that version. + Release a new version that restores compatibility as soon as possible. +- **Do not choose the version early.** Changes collect under `[Unreleased]` in `CHANGELOG.md`, and the version + is chosen at release time from what is there: anything breaking means major, anything added means minor, + and only fixes means patch. + +### Where the version lives + +- `ModuleVersion` in `ScriptMessage/ScriptMessage.psd1` is the source of truth, and has exactly three numeric + parts. It keeps the last released version until a release-prep pull request changes it. +- A prerelease also sets `Prerelease` under `PrivateData.PSData`. For a stable release, leave that key + commented out rather than setting it to an empty string. +- The Git tag, the changelog heading, the GitHub release, and the PowerShell Gallery version all use the same + string, with no `v` prefix: `1.2.0`, or `2.0.0-rc01` for a prerelease. The release workflow refuses to + publish when they disagree. + +### Prereleases + +- A prerelease label may contain only letters, digits, and hyphens. The PowerShell Gallery rejects dots and `+`. +- Use `alpha`, then `beta`, then `rc`, each with a two-digit number: `2.0.0-alpha01`, `2.0.0-beta01`, + `2.0.0-rc01`. The Gallery compares labels as text, so without the leading zero `rc10` would sort before `rc2`. +- Installing a prerelease needs PowerShellGet 1.6.0 or later with `-AllowPrerelease`, or PSResourceGet with + `-Prerelease`. The PowerShellGet built into Windows PowerShell 5.1 cannot install one. A prerelease and the + final version install into the same folder, so the final version replaces the prerelease. + +### Published versions never change + +A released version is never republished, re-tagged, or replaced, and its number is never reused. The PowerShell +Gallery enforces this on its side: packages cannot be deleted, only unlisted, and each publish must be a higher +version than the last. If a release is defective, fix it in a new version. + +## Branches + +ScriptMessage uses the +[Simple Modified Gitflow](https://www.grimadmin.com/article.php/simple-modified-gitflow-workflow) workflow: +Gitflow without release branches, because only one version is maintained at a time. + +| Branch | Created from | Merges into | Purpose | +|---|---|---|---| +| `main` | Permanent | None | Always the latest release. Every merge into `main` is a release and is tagged. | +| `develop` | Permanent | `main` | The integration branch, holding the complete history of the project. | +| `feature/` | `develop` | `develop` | New features, and bug fixes that can wait for the next release. | +| `hotfix/` | `main` | `main`, then `develop` | Urgent fixes to the released version. | + +Rules for the shared branches: + +- **Changes reach `develop` and `main` only through pull requests**, and the tests must pass first. +- **Merge into `main` with a merge commit, never a squash or a rebase.** A squashed commit on `main` does not + exist on `develop`, so every later release pull request would conflict with it. +- **Never rebase or force-push `main` or `develop`.** Release tags cannot move, and rewriting shared history + breaks every contributor's clone. Rebase only your own feature branch, and only before it merges. +- **After every release or hotfix, merge `main` back into `develop`**, so `develop` contains the release commit. +- **Stable release tags go only on `main`.** Prerelease tags may go on `develop`. + +## Changelog + +`CHANGELOG.md` follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). Every pull request with a +user-visible change adds its entry under `## [Unreleased]`, in one of these sections, in this order: `Added`, +`Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. The rules for what an entry says are in the +`CHANGELOG.md` section of [AGENTS.md](./AGENTS.md), and they apply to everyone, not only to AI assistants. + +`[Unreleased]` exists only on `develop`, and only while it has entries. A release renames it to the version +heading, so right after a release there is none. The next pull request that adds an entry also adds the section at +the top, linked to a comparison from the last release: +`## [Unreleased](https://github.com/Sekers/ScriptMessage/compare/1.2.0...develop)`. The Release workflow refuses to +publish a tag whose `CHANGELOG.md` still has an `[Unreleased]` section. + +## Releasing a version + +1. **Choose the version** from the `[Unreleased]` entries, following [Deciding hard cases](#deciding-hard-cases). +2. **Open a release-prep pull request into `develop`** that changes only: + - `ModuleVersion`, and `Prerelease` for a prerelease, in `ScriptMessage/ScriptMessage.psd1`. + - `CHANGELOG.md`: rename `## [Unreleased](...)` to a dated heading such as + `## [1.2.0](https://github.com/Sekers/ScriptMessage/tree/1.2.0) - 2026-09-20`. Do not add a new + `[Unreleased]` section. +3. **Open a pull request from `develop` into `main`**, and merge it with a merge commit once the checks pass. Merge + nothing else into `develop` until then, so no new `[Unreleased]` section reaches `main`. +4. **Tag the merge commit with an annotated tag** (add `-s` to sign it when you have signing set up), and push + the tag: + + ```powershell + git fetch origin + git tag -a 1.2.0 origin/main -m 'ScriptMessage 1.2.0' + git push origin 1.2.0 + ``` + +5. **The Release workflow** (`.github/workflows/Release.yml`) runs these jobs in order and stops at the first + failure: + 1. Confirms the tag is annotated, matches the manifest version, has a dated `CHANGELOG.md` section and no + `[Unreleased]` section, and points at a commit on `main` (or on `develop`, for a prerelease). + 2. Runs the test suite (`.github/workflows/Tests.yml`). + 3. Builds the package once and publishes it to the PowerShell Gallery, in the `psgallery` environment. + 4. Creates the GitHub release from the version's changelog section, attaches the same package, and + publishes the release. +6. **Merge `main` back into `develop`.** + +The GitHub release is created last so that a failed check or publish never leaves behind a public release, which +immutable releases would lock, for a version that did not ship. + +### If the Release workflow fails + +- **Job 1, 2, or 3 failed:** the Gallery has not accepted the package and no GitHub release exists. Fix the + problem through pull requests, delete the tag (a repository admin must bypass the tag ruleset), and tag the new + merge commit with the same version. +- **Only job 4 failed:** the version has shipped to the Gallery. Use **Re-run failed jobs** so only the GitHub + release is retried. Do not re-run all jobs, because the Gallery rejects a second publish of the same version. + +## Hotfixes + +1. Create `hotfix/` from `main`, for example `hotfix/1.2.1`. +2. Fix the problem, add tests, bump the patch version, and add a dated changelog section for the hotfix. +3. Open a pull request into `main`, merge it with a merge commit, and tag and push as in + [Releasing a version](#releasing-a-version), steps 4 and 5. +4. Merge `main` into `develop`. In `CHANGELOG.md`, keep the `[Unreleased]` section from `develop` at the top, + with the hotfix's section below it. + +## Prereleases + +A prerelease follows the same steps, with three differences: the release-prep pull request also sets +`Prerelease`, the changelog heading names the prerelease (`## [2.0.0-rc01](...) - 2026-09-20`), and the tag goes +on the release-prep merge commit on `develop` instead of on `main`. When the final version ships, its changelog +section covers everything since the last stable release, not only the changes since the last prerelease. + +## Repository settings + +A repository admin configures these once in the GitHub repository settings. They are what make the rules above +enforceable: + +- **Branch rulesets for `main` and `develop`:** require a pull request, require the `Workflow lint` and `Pester` + checks to pass, and block force pushes and deletion. For `main`, allow only merge commits. +- **A tag ruleset for release tags:** target tags that look like versions, and block updates and deletion, with + bypass only for admins. +- **Immutable releases:** enabled, so a published release's tag and assets cannot change. +- **A `psgallery` environment:** holds the `PS_GALLERY_KEY` secret, is limited to release tags, and can require a + maintainer's approval. Remove any repository-level copy of the secret. Use a PowerShell Gallery API key scoped + to the ScriptMessage package with only the "Push new or update packages" permission. Gallery API keys expire + after at most 365 days, the Gallery emails the key's owner 10 days before expiry, and an expired key fails the + publish job. + +## Release checklist + +- [ ] Every change listed under `[Unreleased]` has been reviewed and merged into `develop`. +- [ ] The version increment follows [Versioning](#versioning). +- [ ] The release-prep pull request changed only the manifest version and the changelog heading, and added no + `[Unreleased]` section. +- [ ] `develop` was merged into `main` with a merge commit. +- [ ] The tag is annotated, has no `v` prefix, and is on the merge commit. +- [ ] Every job in the Release workflow passed. +- [ ] The version appears on the PowerShell Gallery, and the GitHub release has the right notes and package. +- [ ] `main` was merged back into `develop`. +- [ ] Anything that needs a live Microsoft Graph test was tested separately in a test tenant. The automated tests + never connect to a tenant. diff --git a/Research_Notes/File-Encoding-And-Line-Endings.md b/Research_Notes/File-Encoding-And-Line-Endings.md new file mode 100644 index 0000000..08332d2 --- /dev/null +++ b/Research_Notes/File-Encoding-And-Line-Endings.md @@ -0,0 +1,171 @@ +# File encoding and line endings + +How PowerShell reads script files and configuration files, how Git stores and reports line endings, and what +happened when this repository was normalized to LF. The rules themselves live in `AGENTS.md` and +`.gitattributes`; this file is the evidence behind them. + +Sections 1 to 4 were measured on **2026-09-15** on Windows 11 (10.0.26200), with Windows PowerShell +**5.1.26100.9444**, PowerShell **7.6.6**, and Git **2.54.0.windows.1** with `core.autocrlf=true` set in the +system-wide Git configuration. Section 5 gives its own date. + +**Claims are labelled with their evidence.** + +- **Measured** means observed on the date given. +- **From source** means read from a file in this repository. +- **Unverified** means nobody has tested it. + +> **The one-line summary:** Windows PowerShell 5.1 silently misreads a non-ASCII character in a script file +> that has no byte order mark, and Git hides line-ending changes from `git diff` while still listing the files +> in `git status`. Keep PowerShell files ASCII, and check line endings with `git ls-files --eol`. + +## 1. Measured: a non-ASCII character in a script file without a BOM + +A two-line script that assigns one em dash (U+2014) to a string and prints the string's length, saved as UTF-8: + +| Saved as | Windows PowerShell 5.1 | PowerShell 7 | +| --- | --- | --- | +| UTF-8 without BOM | `length=3` | `length=1` | +| UTF-8 with BOM | `length=1` | `length=1` | + +Windows PowerShell 5.1 read the em dash's three UTF-8 bytes as three characters, with no error or warning. +PowerShell 7 read both files correctly. This is why PowerShell files in this repository are ASCII only rather +than UTF-8 with a BOM: with no non-ASCII characters, whether a file has a BOM makes no difference to either +edition. The row saved with a BOM is the fallback for a module manifest that genuinely needs a non-ASCII +character, since a `.psd1` cannot contain an escape such as `[char]0x00E9`. + +Section 5 measures the same question for the JSON configuration file. + +## 2. Measured: the repository before normalization + +As of commit ff31824, before `.gitattributes` existed: + +- Git's index held all 24 tracked files with LF line endings. +- With `core.autocrlf=true`, 22 of them were checked out with CRLF. `CHANGELOG.md` and `.gitignore` were LF in + the working tree (**Unverified:** why those two differed). +- `ScriptMessage/ScriptMessage.psm1` was the only file with a UTF-8 BOM. +- No tracked file contained a non-ASCII character. + +## 3. Measured: `git status` lists files whose content did not change + +After adding `.gitattributes` (every text type `eol=lf`) and rewriting the 22 CRLF working files as LF: + +| Check | Result | +| --- | --- | +| `git diff --stat` | only `ScriptMessage/ScriptMessage.psm1`, one line (its BOM removal) | +| `git status --short` | all 22 rewritten files listed as modified | +| `git update-index --refresh` | reported each of them as `needs update`; `git status` did not change | +| `git hash-object ` compared with the blob in `git ls-files -s` | identical for the 21 files other than `ScriptMessage.psm1` | +| `git add ` on those 21 | removed them from `git status`; `git diff --cached` stayed empty | + +So after a line-ending or `.gitattributes` change, a file that `git status` lists as modified may have no +content change at all, and refreshing the index does not clear it. Compare `git hash-object` with +`git ls-files -s` before assuming there is something to commit, and after staging, `git diff --cached --stat` +shows what a commit would actually contain. + +## 4. Measured: starting Windows PowerShell 5.1 as a child process on the test machine + +`powershell.exe -NoProfile -NonInteractive -Command "Import-Module .\ScriptMessage\ScriptMessage.psd1"` failed +with "running scripts is disabled on this system". Adding `-ExecutionPolicy Bypass` to that command line, which +applies to that process only, let the module import. PowerShell 7 imported the module without it. + +This comes from the machine's execution policy, not from the module, but it stops any test run that starts 5.1 +in a child process, so a cross-edition test runner needs to pass `-ExecutionPolicy Bypass` or run under a +policy that allows local scripts. + +## 5. Measured: how the configuration file is decoded + +Measured on **2026-09-16** on the same machine, with Windows PowerShell **5.1.26100.9444** and PowerShell +**7.6.6**. The system's legacy (ANSI) code page was **1252** and the culture `en-US`. The tests only read local +files; nothing connected to Microsoft Graph. + +**From source:** in `1.1.0`, `Get-ScriptMessageConfig` (`ScriptMessage/Public/Get-ScriptMessageConfig.ps1`) +reads the file with `Get-Content` and no `-Encoding`, and pipes the lines to `ConvertFrom-Json`. The tables call +that the 1.1.0 reader; it was measured with `develop` at c88393d, whose reader is identical. `Get-ScriptMessageConfig` +now uses the reader the tables call "Current". + +**Method.** Every test file held the same two string settings. `MgApp_CertificateName` was `Soci`, U+00E9, `t`, +U+00E9, a space, and U+20AC, all of which exist in code page 1252. A second setting held U+0141, U+4E2D, and the +surrogate pair for U+1F600, none of which exist in code page 1252. Six files were written byte for byte, and six +more by piping `ConvertTo-Json` to `Set-Content`, `Out-File`, and `>` in each edition. Both editions read every +file with the module's own `Get-ScriptMessageConfig` and with three alternatives, and each value was compared with +what the file's bytes actually hold. + +What each edition's cmdlets wrote: + +| Cmdlet | Windows PowerShell 5.1 | PowerShell 7 | +| --- | --- | --- | +| `Set-Content` | code page 1252, no BOM; characters outside it were lost while writing (U+0141 became `L`, the rest `?`) | UTF-8 without BOM | +| `Out-File` and `>` | UTF-16 LE with BOM | UTF-8 without BOM | + +`ConvertTo-Json` escaped none of the test characters in either edition. + +What each reader returned. "wrong" means different text with no error; no read in the whole test raised an error. + +| File encoding | 1.1.0 reader, 5.1 | 1.1.0 reader, 7 | `-Encoding UTF8`, 5.1 | `-Encoding UTF8`, 7 | Current, 5.1 | Current, 7 | +| --- | --- | --- | --- | --- | --- | --- | +| UTF-8 without BOM | **wrong** | ok | ok | ok | ok | ok | +| UTF-8 with BOM | ok | ok | ok | ok | ok | ok | +| UTF-16 LE with BOM | ok | ok | ok | ok | ok | ok | +| UTF-16 BE with BOM | ok | ok | ok | ok | ok | ok | +| Code page 1252, no BOM | ok | **wrong** | **wrong** | **wrong** | ok | ok | +| ASCII, non-ASCII written as JSON escape sequences | ok | ok | ok | ok | ok | ok | + +- "1.1.0 reader" is `Get-Content` with no `-Encoding` piped to `ConvertFrom-Json`, called through + `Get-ScriptMessageConfig`. +- "`-Encoding UTF8`" is `Get-Content -Encoding UTF8` piped to `ConvertFrom-Json`. It still honored a UTF-16 BOM. + `[System.IO.File]::ReadAllText()` with no encoding argument gave the same result for every file in both editions. +- "Current" is `Get-ScriptMessageConfig` as it reads the file now. It opens a `System.IO.StreamReader` with byte + order mark detection and a strict `UTF8Encoding($false, $true)`. When that throws `DecoderFallbackException`, it + rereads the file with `[System.Text.Encoding]::GetEncoding(0)`. +- The files written by cmdlets gave the same results as the row for the encoding they were written in. + +The misreads were silent. Windows PowerShell 5.1 decoded each byte of a UTF-8 sequence as a separate code page +1252 character (U+00E9 came back as U+00C3 U+00A9). A UTF-8 read of the code page 1252 file turned each +non-ASCII byte into U+FFFD. + +Which code page each API used, first in the process's own culture (`en-US`) and then after setting the thread's +culture to `ja-JP` in the same process: + +| Measurement | 5.1, `en-US` | 5.1, `ja-JP` | 7, `en-US` | 7, `ja-JP` | +| --- | --- | --- | --- | --- | +| `[System.Text.Encoding]::GetEncoding(0).CodePage` | 1252 | 1252 | 1252 | 1252 | +| `[System.Text.Encoding]::Default.CodePage` | 1252 | 1252 | 65001 | 65001 | +| `[System.Globalization.CultureInfo]::CurrentCulture.TextInfo.ANSICodePage` | 1252 | 932 | 1252 | 932 | +| `Get-Content` with no `-Encoding` read the code page 1252 file correctly | yes | yes | no | no | + +So `GetEncoding(0)` follows the system's code page in both editions, as Windows PowerShell 5.1's `Get-Content` +does, and ignores the culture. `Encoding.Default` is UTF-8 on PowerShell 7, and the culture's code page changes +with the culture. + +A file starting with a UTF-8 byte order mark and containing an invalid UTF-8 byte raised no exception from the +strict `StreamReader` in either edition; the byte came back as U+FFFD. So the reader only falls back to the legacy +code page for a file with no byte order mark. + +**What this means for the module:** + +- Adding `-Encoding UTF8` would make Windows PowerShell 5.1 read exactly as PowerShell 7 does with the 1.1.0 + reader: the two gave identical results for all twelve files. It fixes UTF-8 files without a BOM on 5.1, which + is what PowerShell 7's cmdlets write. It breaks 5.1's reading of files in the legacy code page, which is what + 5.1's `Set-Content` writes and which PowerShell 7 already misreads. The module does not use it for that reason. +- The current reader read every file correctly in both editions. `Tests/Get-ScriptMessageConfig.Tests.ps1` + covers UTF-8 with and without a BOM, UTF-16 LE with a BOM, and the legacy code page. +- **From source:** the only configuration settings that hold free text are `MgApp_CertificateName` and + `MgApp_CertificatePath`; the rest are GUIDs, a thumbprint, booleans, names from a fixed set, and encrypted + strings. `Connect-ScriptMessage_MicrosoftGraph` uses `MgApp_CertificatePath` only for `CertificateFile` + authentication, which throws before PowerShell 7.4, so on 5.1 only `MgApp_CertificateName` can be misread in a + way that matters. On PowerShell 7 both settings can be misread from a legacy code page file. + +**What this does not establish:** + +- Only code page 1252 was tested. Another legacy code page, or Windows' "Use Unicode UTF-8 for worldwide + language support" option (which sets the legacy code page to 65001), is **Unverified**. +- The culture test changed only the thread's culture. A machine whose system locale uses another code page was + not tested. +- What `GetEncoding(0)` returns on Linux and macOS is **Unverified**. The reader only reaches it for a file that + is not valid UTF-8. +- A legacy code page file whose non-ASCII bytes happen to form valid UTF-8 would be decoded as UTF-8. Not tested. +- What `Connect-MgGraph` does with a misread `MgApp_CertificateName` was not tested; it needs a certificate in + the store and a connection. +- Which encoding Notepad, VS Code, or other editors use when a user saves a copy of the template is + **Unverified**. +- UTF-16 without a BOM and UTF-32 were not tested. diff --git a/Research_Notes/Microsoft-Graph-Behavior.md b/Research_Notes/Microsoft-Graph-Behavior.md new file mode 100644 index 0000000..7d87b49 --- /dev/null +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -0,0 +1,225 @@ +# Microsoft Graph behavior + +What Microsoft Graph and the Microsoft Graph PowerShell SDK do in the areas this module relies on: +permissions, what a successful send returns, chat creation, attachment and upload limits, how SDK sign-in +persists, and what disconnecting after a send changes. Each claim is tied to the part of the module it affects. + +**Only section 7 was measured against a tenant**, on **2026-09-16**; that section gives the environment. +Documentation was read on **2026-09-15** from the pages listed under Sources, and the SDK source on +**2026-09-16**. Microsoft revises these pages, so re-read a page before relying on a limit or a permission. + +**Claims are labelled with their evidence.** + +- **Measured** means observed in a test, in the environment the section describes. +- **From documentation** means stated on a page listed under Sources, on the date above. +- **From source** means read out of `ScriptMessage/Services/MicrosoftGraph.ps1` or + `ScriptMessage/Public/Send-ScriptMessage.ps1`; it describes what this module does, which is not the same as + what Graph does. +- **From SDK source** means read out of the Microsoft Graph PowerShell SDK source at the release tag listed under + Sources; a later SDK release can behave differently. +- **Inference** means reasoned from documentation or source without a test. +- **Unverified** means nobody has tested it or found it documented. Several of these are assumptions the module + makes today. + +> **The one-line summary:** a successful mail send means Exchange accepted the request, not that the message +> was delivered; posting a Teams chat message needs delegated permissions; and Microsoft's terms of use forbid +> using Teams as a log file, which matters for a module built to send messages from scripts. + +## Sources + +| Page | Last updated (page metadata) | +| --- | --- | +| [user: sendMail](https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0) | 2026-06-19 | +| [Send message in a chat](https://learn.microsoft.com/en-us/graph/api/chat-post-messages?view=graph-rest-1.0) | 2026-05-19 | +| [Create chat](https://learn.microsoft.com/en-us/graph/api/chat-post?view=graph-rest-1.0) | 2025-12-03 | +| [Attach large files to Outlook messages or events](https://learn.microsoft.com/en-us/graph/outlook-large-attachments) | 2025-08-06 | +| [Upload small files](https://learn.microsoft.com/en-us/graph/api/driveitem-put-content?view=graph-rest-1.0) | 2026-08-11 | +| [Use Microsoft Graph PowerShell authentication commands](https://learn.microsoft.com/en-us/powershell/microsoftgraph/authentication-commands?view=graph-powershell-1.0) | 2026-05-06 | +| [Microsoft Graph PowerShell SDK authentication source, tag `2.25.0`](https://github.com/microsoftgraph/msgraph-sdk-powershell/tree/2.25.0/src/Authentication) | none (release tag) | + +## 1. From documentation: permissions for each operation the module performs + +| Operation | Call in the module | Delegated (least privileged) | Application (least privileged) | +| --- | --- | --- | --- | +| Send mail | `Send-MgUserMail` | `Mail.Send` | `Mail.Send` | +| Create a chat | `New-MgChat` | `Chat.Create` | `Chat.Create` | +| Send a chat message | `New-MgChatMessage` | `ChatMessage.Send` | `Teamwork.Migrate.All` only | +| Upload a file to OneDrive | `Invoke-MgGraphRequest -Method PUT` | `Files.ReadWrite` | `Files.ReadWrite.All` | + +`Teamwork.Migrate.All` belongs to the message import (migration) flow, and the page's import example requires +the target chat to be in migration mode. So an app using application permissions can create a chat but cannot +post an ordinary message into it. + +**From source:** with delegated permissions the module requests `email`, `offline_access`, `openid`, and +`profile`; plus `Mail.Send` when Mail is allowed; plus `Chat.Create`, `ChatMessage.Send`, and either +`Chat.Read` or `Chat.ReadBasic` when Chat is allowed; plus `Files.ReadWrite` when +`MgDelegatedPermission_RequestFilesReadWritePermission` is true. With application permissions the module +requests no scopes (the app registration decides) and refuses to send chat, adding a warning to the result. + +**Unverified:** the permissions needed by the other calls the module makes: `Get-MgChat` (finding an existing +group chat), `Get-MgUserDrive` and `Get-MgDriveItem` (the attachment folder), and `Invoke-MgInviteDriveItem` +(sharing uploaded files). Those pages were not read. + +## 2. From documentation: Teams must not be used as a log file + +The Send message in a chat page states: "It's a violation of the terms of use to use Microsoft Teams as a log +file. Only send messages that people will read." The same page says the API "can't create a new chat", so the +chat has to exist before a message is posted. + +**Inference:** a script that posts a chat message for every item it processes is the pattern this forbids. +Chat suits alerts a person should act on; high-volume status output belongs in mail or a log file. User-facing +documentation for the Chat message type should say so. + +**From source:** the module creates or finds the chat before posting, consistent with the page. + +## 3. From documentation: what a successful send returns + +- **sendMail** returns `202 Accepted` with an empty body. The page: "A `202 Accepted` response code indicates + that the request has been accepted; however, it doesn't indicate that the request processing has completed. + Delivery of the message is subject to Exchange Online limitations and throttling." `saveToSentItems` defaults + to true. +- **Send message in a chat** returns `201 Created` with the new chatMessage object. + +**From source:** for Mail, the result's `Status` holds what `Send-MgUserMail -PassThru` returned. For Chat, +`Status` holds the chatMessage object from `New-MgChatMessage`. + +**Inference:** a Mail result with no errors means Exchange accepted the message, not that it was delivered, and +the module has no way to report a later delivery failure. + +**Unverified:** that `Send-MgUserMail -PassThru` returns only `$true`. A comment in the module says so; it was +not checked. + +## 4. From documentation: creating one-on-one and group chats + +The Create chat page: "Only one one-on-one chat can exist between two members. If a one-on-one chat already +exists, this operation returns the existing chat and doesn't create a new one." + +**From source:** for `ChatType` `OneOnOne`, the module calls `New-MgChat` for each recipient on every send and +relies on this to land in the existing chat. For `Group`, it first lists all of the sender's group chats +(`Get-MgChat -All`, expanding members) and posts into one whose members exactly match the sender plus the +recipients, creating a new group chat only when none matches. + +**Unverified:** whether `New-MgChat` for a group chat always creates a new chat even when one with the same +members already exists. The page does not say, and the module's lookup exists on the assumption that it does. + +## 5. From documentation: attachment and upload size limits + +- **Attachments on an Outlook message item:** "you can attach files up to 150 MB to an Outlook message". Under + 3 MB, "do a single POST on the attachments navigation property of the Outlook item"; between 3 MB and 150 MB, + "create an upload session". Creating an upload session for a message needs `Mail.ReadWrite`. Both routes add + the attachment to an existing message (a draft), not to a `sendMail` call. +- **sendMail:** "When using JSON format, you can include a file attachment in the same sendMail action call." + The page gives no size limit for that. +- **OneDrive simple upload** (`PUT .../content`): "This method only supports files up to 250 MB in size." + Larger files need an upload session. + +**From source:** mail attachments go inline in the `sendMail` request as base64 `contentBytes`, and the module +requests `Mail.Send` only. Chat attachments are uploaded with a simple `PUT` to +`root:/Microsoft Teams Chat Files/:/content` in the sender's OneDrive (with a number added to the name if +that name is taken), shared with the chat recipients as read-only with sign-in required and no invitation +email, and then referenced from the chat message. + +### What this does not establish + +- **Unverified:** the largest attachment, or total request size, that `sendMail` accepts inline. The 3 MB figure + is documented for adding an attachment to an existing message, so do not quote it as the `sendMail` limit. +- **Inference:** supporting mail attachments past whatever the inline limit is would mean sending through a + draft message and an upload session, which needs `Mail.ReadWrite`, a broader permission than the module + requests today. +- **Unverified:** what the simple `PUT` does when a file with that name already exists. The page does not say; a + comment in the module says it overwrites, and the module renames the file to avoid the question. +- **Unverified:** that `Microsoft Teams Chat Files` is the folder Teams itself uses for chat attachments. The + module assumes it; none of the pages above mention it. + +## 6. From documentation: how SDK sign-in persists, and other clouds + +- "sign-in persists across PowerShell sessions because Microsoft Graph PowerShell securely caches the token when + using the default `CurrentUser` context scope. If you use the `-ContextScope Process` parameter with + `Connect-MgGraph`, sign-in only persists for the current PowerShell session." +- "`Disconnect-MgGraph` clears the cached token and ends the session for the current context scope, requiring + you to authenticate again for future commands." +- "By default, `Connect-MgGraph` targets the global public cloud." The `-Environment` parameter selects another + cloud, and `Get-MgEnvironment` lists each cloud's Graph endpoint (for example `https://graph.microsoft.us` for + `USGov`). +- The page recommends PowerShell 7 or later when connecting with client secret credentials. + +**From source:** the module calls `Connect-MgGraph` without `-ContextScope` or `-Environment`, and when +`MgDisconnectWhenDone` is true `Send-ScriptMessage` calls `Disconnect-MgGraph` after sending. The chat member +binding and the OneDrive upload URL are built on a hard-coded `https://graph.microsoft.com`. Client secret +authentication is offered on both PowerShell editions. + +**From SDK source:** the `CurrentUser` default applies only to delegated sign-in. Certificate and client secret +connections, which the module uses for application permissions, default to `Process`, and section 7 measures +what that means. `Disconnect-MgGraph` also does less than the page's wording suggests: it clears the in-memory +token cache and deletes `~/.mg/mg.authrecord.json` (the file a delegated sign-in reads to find its cached +account), but leaves the on-disk token cache in place. + +**Inference:** because the endpoint is hard-coded, chat and chat attachments would not work in another cloud even +if `-Environment` were passed. + +### What this does not establish + +- **Unverified, delegated permissions only:** what `Disconnect-MgGraph`, and so `MgDisconnectWhenDone`, does to + other PowerShell sessions of the same Windows user. Reading the SDK source suggests a session already signed in + keeps working, while the next `Connect-MgGraph` in any session prompts again because `mg.authrecord.json` is + gone; neither was tested. Application-permission connections share nothing with other sessions (section 7). + +## 7. Measured: application-permission connections and `MgDisconnectWhenDone` + +**Environment:** measured on 2026-09-16 in PowerShell 7.6.6 and Windows PowerShell 5.1.26100.9444 on Windows 11, +with `Microsoft.Graph.Authentication`, `Microsoft.Graph.Users.Actions`, and `Microsoft.Graph.Teams` 2.25.0 in +both editions. The configuration used `MgPermissionType` `Application` and `MgApp_AuthenticationType` +`CertificateThumbprint`, for an app registration whose token carried only the `Mail.Send` application role. +**No message was sent:** the tests ran `Connect-ScriptMessage` and then, where needed, the `Disconnect-MgGraph` +call that `Send-ScriptMessage` makes after a send, and never called `Send-ScriptMessage`. Both editions gave +the same results. + +**Measured:** + +- After `Connect-ScriptMessage`, `Get-MgContext` reported `AuthType` `AppOnly`, `ContextScope` `Process`, and + `TokenCredentialType` `ClientCertificate`. +- A separate PowerShell process started while connected reported no Graph connection. +- Connecting wrote no token file: `~/.mg` and `%LOCALAPPDATA%\.IdentityService` were unchanged. +- Connecting again in the same process without disconnecting reused the access token from memory (the SDK's + debug output reported `source: Cache`). After `Disconnect-MgGraph`, the next connect requested a new token + (`source: IdentityProvider`). +- `Disconnect-MgGraph` deleted a placeholder `~/.mg/mg.authrecord.json` even though the connection was app-only. +- **A script's own Graph connection does not survive a send.** The test script first connected with the same app + and certificate by subject name, so its connection could be told apart from ScriptMessage's, which uses the + thumbprint. After `Connect-ScriptMessage`, `Get-MgContext` showed ScriptMessage's connection. With the + `Disconnect-MgGraph` step (`MgDisconnectWhenDone` true), no connection remained, and the script's next + `Invoke-MgGraphRequest` failed with "Authentication needed. Please call Connect-MgGraph." Without that step + (`MgDisconnectWhenDone` false), ScriptMessage's connection remained. + +**From SDK source:** `ConnectMgGraph.cs` defaults `ContextScope` to `Process` for the certificate, client secret, +managed identity, and environment variable sign-ins, and to `CurrentUser` only for delegated sign-in. +`GraphSession.cs` keeps one static session holding one connection, so each `Connect-MgGraph` replaces the +process's connection whatever kind it was. `AuthenticationHelpers.LogoutAsync`, which `Disconnect-MgGraph` calls, +clears the in-memory token cache, clears the connection, and deletes `mg.authrecord.json`. + +**From source:** `Send-ScriptMessage` runs `Connect-ScriptMessage` before every send, and `Disconnect-MgGraph` +after it only when the configuration's `MgDisconnectWhenDone` is true, so a configuration without the setting +stays connected. `Connect-ScriptMessage_MicrosoftGraph` passes no `-ContextScope`, so every application +authentication type gets `Process` scope. + +**Inference, for `MgDisconnectWhenDone` with application permissions:** + +- A script that runs in its own process and then exits keeps nothing either way. The only lasting effect of + true is deleting `mg.authrecord.json`, which a delegated sign-in by the same Windows user relies on. +- Neither setting preserves a calling script's own Graph connection. With true, the script is left with no + connection after every send. With false, it is left with ScriptMessage's connection, which works when the + script used the same app and otherwise runs its later Graph commands with ScriptMessage's app and permissions. + `Templates/config_scriptmessage.json` sets `MgDisconnectWhenDone` to false for this reason. +- With true, every send requests a new token. With false, sends in the same process reuse one token until it + expires. + +### What this does not establish + +- **Unverified:** replacement of a calling script's connection made with a different app, delegated permissions, + or a managed identity. Only a connection with the same app was measured; the SDK source says any connection is + replaced. +- **Unverified:** the `CertificateFile`, `CertificateName`, and `ClientSecret` authentication types. The SDK + source gives them `Process` scope too; they were not measured. +- **Unverified:** runspaces sharing a process, such as `ForEach-Object -Parallel` or thread jobs. The static + session suggests they share one connection; this was not tested. +- Nothing in this section was measured with delegated permissions or with an SDK release other than 2.25.0. diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md new file mode 100644 index 0000000..908c3a0 --- /dev/null +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -0,0 +1,237 @@ +# PowerShell language behavior + +PowerShell behaviors that this module's code depends on or trips over. None of this is Microsoft Graph +behavior; it is the language and runtime, recorded here because each item below either explains a bug found +in the module or constrains how a fix has to be written. + +Measured on **2026-09-15** on Windows 11 (10.0.26200), against Windows PowerShell **5.1.26100.9444** and +PowerShell **7.6.6**, using small standalone scripts. The module was not imported and no tenant was involved. +Both editions agreed on every result unless a section says otherwise. + +**Claims are labelled with their evidence.** + +- **Measured** means run on both editions on the date above and the output recorded. +- **From source** means read out of a file in this repository; it describes what the module does, not what + PowerShell does. +- **Unverified** means nobody has tested it. + +> **The one-line summary:** PowerShell's truthiness, variable type constraints, and member enumeration each do +> something reasonable that is not what the code next to them assumed. Test the value you actually have, not +> the value you expect to have. + +## 1. Measured: `-not` cannot tell "not passed" from "passed a zero-valued enum or `$false`" + +With `enum ChatType { OneOnOne; Group }`: + +| Expression | Result | +| --- | --- | +| `-not [ChatType]::OneOnOne` (enum value 0) | `True` | +| `-not [ChatType]::Group` (enum value 1) | `False` | +| A `[ChatType]` parameter the caller did not pass | value is empty (`$null`); `-not` gives `True` | +| A `[bool]` parameter the caller did not pass | value is `False` | +| `-not $false` | `True` | +| `$PSBoundParameters.ContainsKey('ChatType')`, passed `OneOnOne` / not passed | `True` / `False` | +| A `[Nullable[bool]]` variable that was never assigned | `$null` | + +So `if (-not $ChatType) { }` applies the default both when the caller passed nothing and when +the caller explicitly passed `OneOnOne`, and the same test on a `[bool]` treats an explicit `$false` as "not +passed". `$PSBoundParameters.ContainsKey()` is the test that tells them apart. A parameter that genuinely needs +three states (unset, true, false) should be `[Nullable[bool]]`. + +**From source:** `Send-ScriptMessage` fills in `ChatType` and `IncludeBCCInGroupChat` from the configuration +file using exactly this `-not` test, which is why an explicit `-ChatType OneOnOne` or +`-IncludeBCCInGroupChat $false` is replaced by the configuration file's value. + +## 2. Measured: a `[PSCustomObject]` constraint on a variable does not convert a hashtable + +| Code | Resulting type | +| --- | --- | +| `[PSCustomObject]$h = @{ Name = 'x' }` | `System.Collections.Hashtable` | +| `$h = [PSCustomObject]@{ Name = 'x' }` | `System.Management.Automation.PSCustomObject` | + +Only the cast applied directly to a hashtable literal builds a `PSCustomObject`. Putting the type on the +variable looks equivalent and is not. + +**From source:** `ConvertTo-ScriptMessageAttachmentObject` uses the variable form, so its attachment items are +hashtables. The Microsoft Graph service code then calls `.ContainsKey()` on them, which works only because they +are hashtables, so changing either side without the other breaks attachments. + +## 3. Measured: `.Address` on an array reaches the array's own method + +For an array of objects that each have an `Address` property: + +| Expression | Result | +| --- | --- | +| `$Array.Address` | a `PSMethod`, not the addresses | +| `$Array.Name` (a member arrays do not have) | the names, as expected | +| `$Array.ForEach('Address')` | the addresses | +| `$Array \| Select-Object -ExpandProperty Address` | the addresses | + +.NET arrays have an `Address` method, and member access on a collection finds that before it enumerates the +elements. The printed type name differs by edition (`PSMethod` on 5.1, ``PSMethod`1`` on 7); the behavior is +the same. + +**From source:** this is why the module's internal recipient objects use an `AddressObj` property instead of +`Address`, and why result building in `ScriptMessage/Services/MicrosoftGraph.ps1` goes through +`System.Linq.Enumerable`. `.ForEach('Address')` or `Select-Object -ExpandProperty Address` avoids the collision +without renaming the property. + +## 4. Measured: `ExpandString` runs code; `ExpandEnvironmentVariables` does not + +- `$ExecutionContext.InvokeCommand.ExpandString('C:\certs\$([math]::Sqrt(16)).pfx')` returned + `C:\certs\4.pfx`. The `$(...)` subexpression was executed, so any command written inside one runs the same + way. +- `[Environment]::ExpandEnvironmentVariables('%SystemRoot%\x')` returned `C:\Windows\x`. It only substitutes + environment variables. + +**From source:** `Connect-ScriptMessage_MicrosoftGraph` passes the `MgApp_CertificatePath` configuration value +through `ExpandString`, so that setting can execute code written into the configuration file. + +## 5. Measured: looking up a property by a name held in an array + +With `$Config = [pscustomobject]@{ MicrosoftGraph = 'cfg' }`: + +| Code | Result | +| --- | --- | +| `$Config.$(@('MicrosoftGraph'))` | `cfg` | +| `$Config.$(@('MicrosoftGraph','Slack'))` | `$null` | + +A one-element array works as the property name, so code that looks a property up by a variable holding service +names works while there is one service and silently returns nothing once there are two. + +**From source:** `Send-ScriptMessage` looks up each service's configuration with +`$ScriptMessageConfig.$($serviceTypeObj.Service)`, where `Service` is an array holding every requested service. + +## 6. Measured: without a `process` block, piped input binds only the last item + +Piping `'a','b','c'` into an advanced function whose parameter has `ValueFromPipeline = $true`: + +| Function body | Output | +| --- | --- | +| no `process` block | runs once, with `c` | +| a `process { }` block | runs three times, with `a`, `b`, and `c` | + +**From source:** every function in `ScriptMessage/Public/` declares `ValueFromPipeline = $true` on its +parameters and none has a `process` block, so piping several objects into any of them acts on the last object +only. + +## 7. Measured: `System.Web.HttpUtility` and URL encoding + +| Expression | Windows PowerShell 5.1 | PowerShell 7 | +| --- | --- | --- | +| `[System.Web.HttpUtility]::UrlEncode('My File+1.pdf')` | fails on first use in a session: "Unable to find type [System.Web.HttpUtility]"; `My+File%2b1.pdf` later in that same session | `My+File%2b1.pdf` | +| `[uri]::EscapeDataString('My File+1.pdf')` | `My%20File%2B1.pdf` | `My%20File%2B1.pdf` | + +`UrlEncode` produces form encoding, which turns a space into `+`. That is correct in a query string, but in a +URL path a `+` is a literal plus sign. `EscapeDataString` needs no extra assembly in either edition and encodes +a space as `%20`. + +**Measured (2026-09-16; Windows PowerShell 5.1.26100.9444 and PowerShell 7.6.6, Pester 6.1.0, Windows 11 build +26200):** sending a chat attachment through `Send-ScriptMessage` with every Microsoft Graph cmdlet mocked, so no +Graph module was imported, and with the upload still calling `UrlEncode` as it did before the switch to +`EscapeDataString`. Under 5.1, the first upload in the session raised "Unable to find type +[System.Web.HttpUtility]", which arrived in the result's `Error` property with `Status` left null, and a second +upload later in that same session succeeded, building the path `My+File%2b1.pdf` from the filename +`My File+1.pdf`. Under PowerShell 7 both uploads succeeded and built the same path. A failure here depends on +what has already run in the session, so it can look intermittent. + +**From source:** the Microsoft Graph chat attachment upload in `Send-ScriptMessage_MicrosoftGraph` builds its +drive item path with `[uri]::EscapeDataString`. + +### What this does not establish + +- **Unverified:** whether importing the Microsoft Graph modules loads `System.Web` on Windows PowerShell 5.1. + An attempt on 2026-09-15 could not import `Microsoft.Graph.Authentication` 2.25.0 in a non-interactive 5.1 + session on the test machine (its format file failed the execution policy check), so this is still open. The + 2026-09-16 run above mocked the Graph cmdlets instead of importing the modules, so it says nothing about this + either. +- **Unverified:** why a later reference to the type succeeded in a session where the first had failed. Only the + outcome was observed, not the cause. +- **Unverified:** what name OneDrive gives a file uploaded through a path containing `+`. Nothing was uploaded. + +## 8. Measured: .NET file methods and PowerShell paths + +Measured on **2026-09-16** with the same editions, in a standalone script whose PowerShell location +(`Push-Location`) was a test folder while the process's working directory was the repository root. A PowerShell +drive was created with `New-PSDrive -PSProvider FileSystem`. + +| Path given | `Get-Content -Path` | `[System.IO.File]::ReadAllText()` on the path as given | `ReadAllText()` on `$PSCmdlet.GetUnresolvedProviderPathFromPSPath()` | +| --- | --- | --- | --- | +| relative, `.\file.json` | read | failed | read | +| on a PowerShell drive | read | not tested | read | +| folder name containing `[x]` | failed | not tested | read | +| wildcard matching one file, `file*.json` | read | not tested | failed | +| `~\file.json` | not tested | not tested | resolved under `$HOME` (not read) | + +- .NET resolves a relative path against the process's working directory, which `Set-Location` and + `Push-Location` do not change, so a relative path has to be resolved by PowerShell first. +- `Get-Content -Path` treats `[` and `]` as wildcard characters, so a folder or file name containing them does + not match itself. `GetUnresolvedProviderPathFromPSPath` takes the path literally: it handles relative paths, + drives, and `~`, never expands wildcards, and does not check that the file exists. + +**From source:** `Get-ScriptMessageConfig` resolves its `-Path` with `GetUnresolvedProviderPathFromPSPath` and then +opens the file with .NET, so a configuration file path containing brackets works and a wildcard in it does not. +`1.1.0` opened the file with `Get-Content -Path`. + +## 9. Measured: a `PSCustomObject` in a one-item array converts to an empty string + +Measured on **2026-09-16** with the same editions, in a standalone script with `$OFS` unset. With +`$Obj = [pscustomobject]@{ Name = 'A'; AddressObj = 'a@domain.com' }`, `$One = @($Obj)`, and +`$Two = @($Obj, $Obj)`: + +| Expression | Result | +| --- | --- | +| `[string]$Obj` or `"$Obj"` | `@{Name=A; AddressObj=a@domain.com}` | +| `$Obj.ToString()` | an empty string | +| `[string]$One` or `"$One"` | an empty string | +| `[string]$Two` | a single space | +| `[string]::IsNullOrEmpty($Obj)` | `False` | +| `[string]::IsNullOrEmpty($One)` | `True` | +| `[string]::IsNullOrEmpty($Two)` | `False` (`IsNullOrWhiteSpace` gives `True`) | +| `[string]::IsNullOrEmpty($One.AddressObj)` | `False` | +| `[string]@(@{ Name = 'x' })`, a hashtable in a one-item array | `System.Collections.Hashtable`, so `IsNullOrEmpty` gives `False` | +| `[string]@('a@domain.com')` | `a@domain.com` | +| `[string]::IsNullOrEmpty(@())` | `True` | + +So `[string]::IsNullOrEmpty` cannot tell an empty array from one holding a single `PSCustomObject`, and for an +array of those objects its answer changes between one item and two. + +The same holds for values arriving through a parameter: + +| Parameter | Argument | `[string]::IsNullOrEmpty()` | `.Count` | +| --- | --- | --- | --- | +| `[pscustomobject]$Recipient` | `$Obj` | `False` | `$null` on 5.1, `1` on 7 | +| `[pscustomobject]$Recipient` | `$One` | `True` | `1` | +| `[array]$Attachment` | a lone `PSCustomObject` | `True` | `1` | +| `[array]$Attachment` | a lone hashtable | `False` | `1` | + +An `[array]` parameter wraps a lone argument in a one-item array (both arguments above arrived as `Object[]`), so +a single `PSCustomObject` passed to one is empty by this test. `.Count` on a lone `PSCustomObject` is the only +result in this section where the editions differ. + +**From source:** + +- `ConvertTo-ScriptMessageRecipientObject` and `ConvertTo-IMicrosoftGraphRecipient` both take a `[pscustomobject]` + parameter and pair `[string]::IsNullOrEmpty` with a second test: `.Count -lt 1` in the first, + `[string]::IsNullOrEmpty($EmailAddress.AddressObj)` in the second. `Send-ScriptMessage` keeps the converted + `To`, `CC`, `BCC`, and `ReplyTo` lists in `[array]` variables, so a single recipient reaches + `ConvertTo-IMicrosoftGraphRecipient` as a one-item array, and only the second test keeps it from being returned + as `$null`. For a lone object the `IsNullOrEmpty` half is already `False`, so the `.Count` difference between + editions does not change either function's result. +- `ConvertTo-IMicrosoftGraphConversationMember` and `ConvertTo-IMicrosoftGraphDriveInvite` use + `[string]::IsNullOrEmpty` alone, on email address strings, which are unaffected. +- `ConvertTo-ScriptMessageAttachmentObject` uses it alone on its `[array]` parameter, which receives + `Send-ScriptMessage -Attachment` as given. Hashtables and path strings, the supported types, are unaffected. A + single `PSCustomObject`, which is not supported, comes back as `$null`, the same as no attachment, while two of + them reach the function's "Unexpected attachment object type." error. `ConvertTo-IMicrosoftGraphAttachment` and + `ConvertTo-IMicrosoftGraphChatMessageAttachment` also use it alone, on items they call `.ContainsKey()` on, so + they expect hashtables. + +### What this does not establish + +- **Unverified:** why an array converts differently. The results fit converting each element with `ToString()` + and joining the results with a space, but that is inferred from the outputs, not read from PowerShell's source. +- Only `[string]` casts, string expansion, `[string]::IsNullOrEmpty`, and `[string]::IsNullOrWhiteSpace` were + tested. Truthiness tests such as `if ($One)` or `-not $One`, and other .NET methods taking a string argument, + were not. +- A script that sets `$OFS` was not tested. diff --git a/ScriptMessage/Public/Connect-ScriptMessage.ps1 b/ScriptMessage/Public/Connect-ScriptMessage.ps1 index 951cd20..746dcd8 100644 --- a/ScriptMessage/Public/Connect-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Connect-ScriptMessage.ps1 @@ -42,7 +42,7 @@ function Connect-ScriptMessage ServiceConfig = Get-ScriptMessageConfig -Service $Service } - # Connect to the proper service. + # Connect to the proper service. Each service checks for the modules its allowed message types need. switch ($Service) { MicrosoftGraph {Connect-ScriptMessage_MicrosoftGraph @ConnectionParameters} diff --git a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 index 4545918..639b962 100644 --- a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 +++ b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 @@ -48,7 +48,27 @@ function Get-ScriptMessageConfig # Get Config and Secrets try { - $ScriptMessageConfig = Get-Content -Path "$Path" -ErrorAction 'Stop' | ConvertFrom-Json + # .NET resolves a relative path against the process directory, not PowerShell's current location. + $FullPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path) + + # A byte order mark sets the encoding when the file has one. Otherwise the file is read as UTF-8, or in the + # system's legacy code page when it is not valid UTF-8. See section 5 of + # Research_Notes/File-Encoding-And-Line-Endings.md. + $Reader = [System.IO.StreamReader]::new($FullPath, [System.Text.UTF8Encoding]::new($false, $true), $true) + try + { + $ConfigText = $Reader.ReadToEnd() + } + catch [System.Text.DecoderFallbackException] + { + $ConfigText = [System.IO.File]::ReadAllText($FullPath, [System.Text.Encoding]::GetEncoding(0)) + } + finally + { + $Reader.Dispose() + } + + $ScriptMessageConfig = $ConfigText | ConvertFrom-Json if (-not ($null -eq $Service)) { return $ScriptMessageConfig.$Service diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index fa229d5..cd30b05 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -251,19 +251,20 @@ function Send-ScriptMessage # Set the necessary configuration variables. $ScriptMessageConfig = Get-ScriptMessageConfig - # Make sure that at least one of, To, CC, or BCC is provided. - if ([string]::IsNullOrEmpty($To) -and [string]::IsNullOrEmpty($CC) -and [string]::IsNullOrEmpty($BCC)) - { - throw 'Please provide at least one parameter value for any of the following: To, CC, or BCC' - } - # Convert recipient types into properly formatted PSObject. $From = ConvertTo-ScriptMessageRecipientObject -Recipient $From # Note that From is NOT an array. There should only be one. - [array]$ReplyTo = ConvertTo-ScriptMessageRecipientObject -Recipient $ReplyTo - [array]$To = ConvertTo-ScriptMessageRecipientObject -Recipient $To - [array]$CC = ConvertTo-ScriptMessageRecipientObject -Recipient $CC + [array]$ReplyTo = ConvertTo-ScriptMessageRecipientObject -Recipient $ReplyTo + [array]$To = ConvertTo-ScriptMessageRecipientObject -Recipient $To + [array]$CC = ConvertTo-ScriptMessageRecipientObject -Recipient $CC [array]$BCC = ConvertTo-ScriptMessageRecipientObject -Recipient $BCC + # Make sure that at least one To, CC, or BCC recipient has an address. + $RecipientsWithAddress = @(@($To) + @($CC) + @($BCC) | Where-Object {($null -ne $_) -and (-not [string]::IsNullOrWhiteSpace($_.AddressObj))}) + if ($RecipientsWithAddress.Count -eq 0) + { + throw 'Please provide at least one parameter value for any of the following: To, CC, or BCC' + } + # Convert body into properly formatted PSObject. $Body = ConvertTo-ScriptMessageBodyObject -Body $Body diff --git a/ScriptMessage/ScriptMessage.psd1 b/ScriptMessage/ScriptMessage.psd1 index 3f67243..9a8b542 100644 --- a/ScriptMessage/ScriptMessage.psd1 +++ b/ScriptMessage/ScriptMessage.psd1 @@ -12,7 +12,7 @@ RootModule = 'ScriptMessage.psm1' # Version number of this module. -ModuleVersion = '1.1.0' +ModuleVersion = '1.1.1' # Supported PSEditions CompatiblePSEditions = @('Desktop','Core') @@ -102,10 +102,10 @@ PrivateData = @{ PSData = @{ # Tags applied to this module. These help with module discovery in online galleries. - Tags = @('API', 'PowerShell', 'MicrosoftGraph', 'Graph', "Email", "Chat", "Messaging") + Tags = @('API', 'PowerShell', 'MicrosoftGraph', 'Graph', "Email", "Chat", "Messaging", 'PSEdition_Desktop', 'PSEdition_Core', 'Windows') # A URL to the license for this module. - LicenseUri = 'https://github.com/Sekers/ScriptMessage/blob/master/LICENSE' + LicenseUri = 'https://github.com/Sekers/ScriptMessage/blob/main/LICENSE' # A URL to the main website for this project. ProjectUri = 'https://github.com/Sekers/ScriptMessage' @@ -114,7 +114,7 @@ PrivateData = @{ # IconUri = '' # ReleaseNotes of this module - ReleaseNotes = 'https://github.com/Sekers/ScriptMessage/blob/master/CHANGELOG.md' + ReleaseNotes = 'https://github.com/Sekers/ScriptMessage/blob/main/CHANGELOG.md' # Prerelease string of this module # Prerelease = '' diff --git a/ScriptMessage/ScriptMessage.psm1 b/ScriptMessage/ScriptMessage.psm1 index 8daa981..e8998a0 100644 --- a/ScriptMessage/ScriptMessage.psm1 +++ b/ScriptMessage/ScriptMessage.psm1 @@ -1,4 +1,4 @@ -# Global Variables +# Global Variables New-Variable -Name 'ScriptMessage_Global_CachedServiceContext' -Value ([PSCustomObject]@{}) -Scope Global -Force # Aliases diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index 6854b0e..35abe37 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -258,6 +258,11 @@ function Connect-ScriptMessage_MicrosoftGraph [pscustomobject]$ServiceConfig ) + # Collect the connection settings from the service configuration. + $MgPermissionType = $ServiceConfig.MgPermissionType + $MgTenantID = $ServiceConfig.MgTenantID + $MgClientID = $ServiceConfig.MgClientID + # Check For MicrosoftGraph Modules # Don't import the entire 'Microsoft.Graph' module. Only import the needed sub-modules. $RequiredModules = [System.Collections.Generic.List[Object]]::new() @@ -284,7 +289,7 @@ function Connect-ScriptMessage_MicrosoftGraph } # If uploads are enabled (based on the config item 'MgDelegatedPermission_RequestFilesReadWritePermission' being set to true), check for the needed module. - if ($ServiceConfig.MgDelegatedPermission_RequestFilesReadWritePermission -eq $true) + if (($ServiceConfig.MgDelegatedPermission_RequestFilesReadWritePermission -eq $true) -and ($MgPermissionType -eq 'Delegated')) { [string]$ModuleName = 'Microsoft.Graph.Files' # Used for Get-MgUserDrive Import-Module -Name $ModuleName -ErrorAction SilentlyContinue @@ -301,10 +306,6 @@ function Connect-ScriptMessage_MicrosoftGraph } # Connect to the Microsoft Graph API. - $MgPermissionType = $ServiceConfig.MgPermissionType - $MgTenantID = $ServiceConfig.MgTenantID - $MgClientID = $ServiceConfig.MgClientID - switch ($MgPermissionType) { Delegated { @@ -348,7 +349,7 @@ function Connect-ScriptMessage_MicrosoftGraph 'Chat.ReadBasic' # Allows an app to read the members and descriptions of one-to-one and group chat threads, on behalf of the signed-in user. ) } - if ($ServiceConfig.MgDelegatedPermission_RequestFilesReadWritePermission -eq $true) + if ($ServiceConfig.MgDelegatedPermission_RequestFilesReadWritePermission -eq $true) # TODO: Do I want to allow 'Files.ReadWrite' for delegated email as well? { $MicrosoftGraphScopes += @( 'Files.ReadWrite' # Allows the app to read, create, update and delete the signed-in user's files. @@ -384,7 +385,7 @@ function Connect-ScriptMessage_MicrosoftGraph $MgApp_EncryptedCertificatePassword = $ServiceConfig.MgApp_EncryptedCertificatePassword if ([string]::IsNullOrEmpty($MgApp_EncryptedCertificatePassword)) { - $NewMessage = "Cannot access .pfx private key certificate file and no password has been provided." + $NewMessage = "Cannot access Microsoft Graph .pfx private key certificate file and no password has been provided." throw $NewMessage } else @@ -761,6 +762,20 @@ function Send-ScriptMessage_MicrosoftGraph } } + # Convert Parameters to IMicrosoft* + $Message = @{} + if (-not [string]::IsNullOrEmpty($Body.Content)) + { + if ([string]::IsNullOrEmpty($Body.ContentType)) # Don't send 'ContentType' if not provided. It will default to 'Text' + { + [hashtable]$Message['Body'] = ConvertTo-IMicrosoftGraphItemBody -Content $Body.Content + } + else + { + [hashtable]$Message['Body'] = ConvertTo-IMicrosoftGraphItemBody -Content $Body.Content -ContentType $Body.ContentType + } + } + # Upload and add any attachments, if needed. # TODO: Check for scope permissions. # Cannot use Set-MgDriveItemContent because it forces a filepath to be provided and we want to provide content directly sometimes. if (-not [string]::IsNullOrEmpty($Attachment)) @@ -802,7 +817,7 @@ function Send-ScriptMessage_MicrosoftGraph } # Encode the filename to handle special characters in the filename when used in the URI. - $AttachmentFileName_Encoded = [System.Web.HttpUtility]::UrlEncode($AttachmentFileName) + $AttachmentFileName_Encoded = [uri]::EscapeDataString($AttachmentFileName) # Upload File $DriveItemId = "$TeamsChatFolder/$($AttachmentFileName_Encoded):" @@ -820,19 +835,7 @@ function Send-ScriptMessage_MicrosoftGraph $DriveInviteResult = Invoke-MgInviteDriveItem -DriveId $MgUserDrive.Id -DriveItemId $UploadDriveItemResult.id -BodyParameter $DriveInviteParams } - # Convert Parameters to IMicrosoft* - $Message = @{} - if (-not [string]::IsNullOrEmpty($Body.Content)) - { - if ([string]::IsNullOrEmpty($Body.ContentType)) # Don't send 'ContentType' if not provided. It will default to 'Text' - { - [hashtable]$Message['Body'] = ConvertTo-IMicrosoftGraphItemBody -Content $Body.Content - } - else - { - [hashtable]$Message['Body'] = ConvertTo-IMicrosoftGraphItemBody -Content $Body.Content -ContentType $Body.ContentType - } - } + # Convert the uploaded files to chat message attachments. $Message['Attachment'] = [array](ConvertTo-IMicrosoftGraphChatMessageAttachment -MgDriveItem $MgDriveItem) $ChatParams = [ordered]@{ diff --git a/Templates/config_scriptmessage.json b/Templates/config_scriptmessage.json index 2344afc..d6cb78e 100644 --- a/Templates/config_scriptmessage.json +++ b/Templates/config_scriptmessage.json @@ -1,10 +1,12 @@ { "MicrosoftGraph": { - "AllowableMessageTypes": ["Mail", "Chat"], + "AllowableMessageTypes": ["Mail","Chat"], + "MailType": "Group", "ChatType": "Group", "IncludeBCCInGroupChat": false, "MgPermissionType": "Application", "MgDelegatedPermission_RequestChatReadPermission": true, + "MgDelegatedPermission_RequestFilesReadWritePermission": true, "MgDisconnectWhenDone": false, "MgTenantID": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "MgClientID": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", diff --git a/Tests/Connect-ScriptMessage.Tests.ps1 b/Tests/Connect-ScriptMessage.Tests.ps1 new file mode 100644 index 0000000..ed608af --- /dev/null +++ b/Tests/Connect-ScriptMessage.Tests.ps1 @@ -0,0 +1,70 @@ +# Pester 5 or later. Run from the repository root: +# Invoke-Pester -Path .\Tests +# Nothing here connects to a tenant, and the Microsoft Graph modules do not need to be installed. + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..\ScriptMessage\ScriptMessage.psd1') -Force + + # Pester can only mock a command that exists, so define a stand-in for the Microsoft Graph cmdlet inside the + # module before mocking it. It throws if a test reaches it without a mock. + InModuleScope ScriptMessage { + function script:Connect-MgGraph { param($Scopes, $TenantId, $ClientId, $Certificate, $CertificateName, $CertificateThumbprint, $ClientSecretCredential) throw 'Connect-MgGraph stand-in called without a mock.' } + } +} + +AfterAll { + Remove-Module ScriptMessage -Force -ErrorAction SilentlyContinue +} + +Describe 'Connect-ScriptMessage Microsoft Graph module check' { + BeforeAll { + Mock -ModuleName ScriptMessage Connect-MgGraph { } + Mock -ModuleName ScriptMessage Import-Module { } + + # Only the Microsoft Graph sub-modules a chat-only setup needs are loaded. New-Module builds real module + # objects without importing them, so the check compares against the same type Get-Module returns. + Mock -ModuleName ScriptMessage Get-Module { + $Modules = @( + New-Module -Name 'Microsoft.Graph.Authentication' -ScriptBlock { } + New-Module -Name 'Microsoft.Graph.Teams' -ScriptBlock { } + ) + if ($Name) { $Modules | Where-Object { $_.Name -in $Name } } else { $Modules } + } + } + + Context 'A configuration that allows only Chat' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + [pscustomobject]@{ + AllowableMessageTypes = @('Chat') + MgPermissionType = 'Delegated' + MgTenantID = 'tenant-id' + MgClientID = 'client-id' + } + } + } + + It 'connects without Microsoft.Graph.Users.Actions' { + { Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop } | Should -Not -Throw + Should -Invoke -ModuleName ScriptMessage Connect-MgGraph -Times 1 -Exactly + } + } + + Context 'A configuration that allows Mail' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + [pscustomobject]@{ + AllowableMessageTypes = @('Mail', 'Chat') + MgPermissionType = 'Delegated' + MgTenantID = 'tenant-id' + MgClientID = 'client-id' + } + } + } + + It 'reports Microsoft.Graph.Users.Actions as missing and does not connect' { + { Connect-ScriptMessage -Service MicrosoftGraph -ErrorAction Stop } | Should -Throw '*: Microsoft.Graph.Users.Actions' + Should -Invoke -ModuleName ScriptMessage Connect-MgGraph -Times 0 -Exactly + } + } +} diff --git a/Tests/Get-ScriptMessageConfig.Tests.ps1 b/Tests/Get-ScriptMessageConfig.Tests.ps1 new file mode 100644 index 0000000..44da781 --- /dev/null +++ b/Tests/Get-ScriptMessageConfig.Tests.ps1 @@ -0,0 +1,106 @@ +# Pester 5 or later. Run from the repository root: +# Invoke-Pester -Path .\Tests +# Nothing here connects to a tenant. Every configuration file is written to Pester's TestDrive. + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..\ScriptMessage\ScriptMessage.psd1') -Force + + # U+00E9 (e with an acute accent) exists in most legacy code pages. U+4E2D (a CJK character) exists in no + # single-byte code page, so it only survives a Unicode encoding. + $AccentedName = 'Soci' + [char]0x00E9 + 't' + [char]0x00E9 + $UnicodeName = $AccentedName + ' ' + [char]0x4E2D + + # Writes a configuration file holding one MgApp_CertificateName, as exactly the given preamble and encoding. + function New-TestConfigFile + { + param( + [string]$Path, + [string]$CertificateName, + [System.Text.Encoding]$Encoding, + [byte[]]$Preamble = @() + ) + + $Json = '{ "MicrosoftGraph": { "MgApp_CertificateName": "' + $CertificateName + '" } }' + [System.IO.File]::WriteAllBytes($Path, [byte[]]($Preamble + $Encoding.GetBytes($Json))) + } +} + +AfterAll { + Remove-Module ScriptMessage -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-ScriptMessageConfig file encodings' { + It 'reads UTF-8 without a byte order mark' { + $Path = Join-Path $TestDrive 'utf8-no-bom.json' + New-TestConfigFile -Path $Path -CertificateName $UnicodeName -Encoding ([System.Text.UTF8Encoding]::new($false)) + + (Get-ScriptMessageConfig -Path $Path -Service MicrosoftGraph).MgApp_CertificateName | Should -BeExactly $UnicodeName + } + + It 'reads UTF-8 with a byte order mark' { + $Path = Join-Path $TestDrive 'utf8-bom.json' + New-TestConfigFile -Path $Path -CertificateName $UnicodeName -Encoding ([System.Text.UTF8Encoding]::new($false)) -Preamble 0xEF, 0xBB, 0xBF + + (Get-ScriptMessageConfig -Path $Path -Service MicrosoftGraph).MgApp_CertificateName | Should -BeExactly $UnicodeName + } + + It 'reads UTF-16 LE with a byte order mark' { + $Path = Join-Path $TestDrive 'utf16le-bom.json' + New-TestConfigFile -Path $Path -CertificateName $UnicodeName -Encoding ([System.Text.UnicodeEncoding]::new($false, $false)) -Preamble 0xFF, 0xFE + + (Get-ScriptMessageConfig -Path $Path -Service MicrosoftGraph).MgApp_CertificateName | Should -BeExactly $UnicodeName + } + + It 'reads a file saved in the system legacy code page' { + $Legacy = [System.Text.Encoding]::GetEncoding(0) + $LegacyBytes = $Legacy.GetBytes($AccentedName) + if (($Legacy.CodePage -eq 65001) -or -not ($LegacyBytes | Where-Object { $_ -gt 0x7F })) + { + Set-ItResult -Skipped -Because "code page $($Legacy.CodePage) does not store U+00E9 as a byte that is invalid UTF-8" + } + + $Path = Join-Path $TestDrive 'legacy-code-page.json' + New-TestConfigFile -Path $Path -CertificateName $AccentedName -Encoding $Legacy + + (Get-ScriptMessageConfig -Path $Path -Service MicrosoftGraph).MgApp_CertificateName | Should -BeExactly $AccentedName + } +} + +Describe 'Get-ScriptMessageConfig paths' { + BeforeAll { + # ASCII only, so these tests depend on how the path is found and not on how the file is decoded. + $AsciiName = 'Path test' + } + + It 'reads a path relative to the current PowerShell location' { + New-TestConfigFile -Path (Join-Path $TestDrive 'relative.json') -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false)) + + Push-Location -LiteralPath $TestDrive + try + { + (Get-ScriptMessageConfig -Path '.\relative.json' -Service MicrosoftGraph).MgApp_CertificateName | Should -BeExactly $AsciiName + } + finally + { + Pop-Location + } + } + + It 'reads a path on a PowerShell drive' { + New-TestConfigFile -Path (Join-Path $TestDrive 'drive.json') -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false)) + + (Get-ScriptMessageConfig -Path 'TestDrive:\drive.json' -Service MicrosoftGraph).MgApp_CertificateName | Should -BeExactly $AsciiName + } + + It 'reads a path containing square brackets' { + $Folder = [System.IO.Directory]::CreateDirectory((Join-Path $TestDrive 'Config [old]')).FullName + $Path = Join-Path $Folder 'brackets.json' + New-TestConfigFile -Path $Path -CertificateName $AsciiName -Encoding ([System.Text.UTF8Encoding]::new($false)) + + (Get-ScriptMessageConfig -Path $Path -Service MicrosoftGraph).MgApp_CertificateName | Should -BeExactly $AsciiName + } + + It 'reports a file that does not exist' { + { Get-ScriptMessageConfig -Path (Join-Path $TestDrive 'missing.json') } | Should -Throw -ExpectedMessage "Can't find the JSON configuration file*" + } +} diff --git a/Tests/Invoke-Tests.ps1 b/Tests/Invoke-Tests.ps1 new file mode 100644 index 0000000..e0414fc --- /dev/null +++ b/Tests/Invoke-Tests.ps1 @@ -0,0 +1,203 @@ +<# + .SYNOPSIS + Runs the Pester tests under Windows PowerShell 5.1 and PowerShell 7, and exits with a non-zero code if any + test fails. + + .DESCRIPTION + Each edition runs the tests in its own child process, so both import the module from scratch. Both editions + need Pester 5 or later. When either edition does not have it, or when -UsePinnedPester is given, both use a + pinned Pester version, which is saved once to a temporary folder with Save-PSResource (this needs PowerShell 7). + + .PARAMETER Edition + Which PowerShell editions to run the tests under: Both (the default), Core, or Desktop. + + .PARAMETER UsePinnedPester + Use the pinned Pester version even when Pester 5 or later is already installed, so every run uses the same + version. The CI workflow uses this. + + .EXAMPLE + .\Tests\Invoke-Tests.ps1 + + .EXAMPLE + .\Tests\Invoke-Tests.ps1 -Edition Core +#> + +[CmdletBinding()] +param( + [ValidateSet('Both', 'Core', 'Desktop')] + [string]$Edition = 'Both', + + [switch]$UsePinnedPester, + + # The script starts itself in each edition's process with -Stage to do one part of the work there. + [Parameter(DontShow = $true)] + [ValidateSet('CheckPester', 'SavePester', 'Run')] + [string]$Stage, + + # For the SavePester stage, the folder to save Pester into. For the Run stage, the pinned Pester manifest. + [Parameter(DontShow = $true)] + [string]$PesterPath +) + +$ErrorActionPreference = 'Stop' + +$PinnedPesterVersion = '6.1.0' + +switch ($Stage) +{ + 'CheckPester' + { + if (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version -ge [version]'5.0' }) + { + exit 0 + } + exit 1 + } + 'SavePester' + { + New-Item -ItemType Directory -Force -Path $PesterPath | Out-Null + Save-PSResource -Name Pester -Version $PinnedPesterVersion -Path $PesterPath -TrustRepository + exit 0 + } + 'Run' + { + $ProgressPreference = 'SilentlyContinue' + if ($PesterPath) + { + Import-Module -Name $PesterPath + } + else + { + Import-Module -Name Pester -MinimumVersion 5.0 + } + + $Configuration = New-PesterConfiguration + $Configuration.Run.Path = $PSScriptRoot + $Configuration.Run.PassThru = $true + $Configuration.Output.Verbosity = 'Detailed' + $Result = Invoke-Pester -Configuration $Configuration + + # Every stage must exit here; otherwise the child process would continue into the code below and start + # test runs of its own. + if ($Result.Result -eq 'Passed') + { + exit 0 + } + exit 1 + } +} + +# Starts this script in another PowerShell process to run one stage there. +function Invoke-Stage +{ + param( + [string]$Executable, + [string]$StageName, + [string]$StagePesterPath + ) + + $Arguments = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath, '-Stage', $StageName) + if ($StagePesterPath) + { + $Arguments += @('-PesterPath', $StagePesterPath) + } + & $Executable @Arguments +} + +$OnWindows = ($PSVersionTable.PSEdition -eq 'Desktop') -or $IsWindows + +$Editions = [ordered]@{} +if ($Edition -in 'Both', 'Core') +{ + $Editions['PowerShell 7'] = 'pwsh' +} +if ($Edition -in 'Both', 'Desktop') +{ + if ($OnWindows) + { + $Editions['Windows PowerShell 5.1'] = 'powershell' + } + elseif ($Edition -eq 'Desktop') + { + throw 'Windows PowerShell 5.1 is only available on Windows.' + } + else + { + Write-Warning 'Skipping Windows PowerShell 5.1, which is only available on Windows.' + } +} + +foreach ($Executable in $Editions.Values) +{ + if (-not (Get-Command -Name $Executable -CommandType Application -ErrorAction SilentlyContinue)) + { + throw "Cannot find '$Executable'. Install it, or use -Edition to run only the other edition." + } +} + +# Use the installed Pester when every edition has version 5 or later. Otherwise use the pinned version for all of +# them, so both editions always run the same Pester. +$NeedsPinnedPester = [bool]$UsePinnedPester +if (-not $NeedsPinnedPester) +{ + foreach ($Executable in $Editions.Values) + { + Invoke-Stage -Executable $Executable -StageName 'CheckPester' + if ($LASTEXITCODE -ne 0) + { + $NeedsPinnedPester = $true + } + } +} + +$PesterManifest = '' +if ($NeedsPinnedPester) +{ + $SaveRoot = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'ScriptMessage-TestModules' + $PesterManifest = Join-Path -Path (Join-Path -Path (Join-Path -Path $SaveRoot -ChildPath 'Pester') -ChildPath $PinnedPesterVersion) -ChildPath 'Pester.psd1' + if (-not (Test-Path -LiteralPath $PesterManifest)) + { + if (-not (Get-Command -Name 'pwsh' -CommandType Application -ErrorAction SilentlyContinue)) + { + throw "Pester 5 or later is not installed, and PowerShell 7 is needed to save Pester $PinnedPesterVersion. Install either one." + } + + Write-Host "Saving Pester $PinnedPesterVersion to $SaveRoot" + Invoke-Stage -Executable 'pwsh' -StageName 'SavePester' -StagePesterPath $SaveRoot + if (($LASTEXITCODE -ne 0) -or (-not (Test-Path -LiteralPath $PesterManifest))) + { + throw "Could not save Pester $PinnedPesterVersion to $SaveRoot." + } + } +} + +$ExitCodes = [ordered]@{} +foreach ($Name in $Editions.Keys) +{ + Write-Host '' + Write-Host "===== $Name =====" + Invoke-Stage -Executable $Editions[$Name] -StageName 'Run' -StagePesterPath $PesterManifest + $ExitCodes[$Name] = $LASTEXITCODE +} + +Write-Host '' +Write-Host '===== Summary =====' +$AnyFailed = $false +foreach ($Name in $ExitCodes.Keys) +{ + if ($ExitCodes[$Name] -eq 0) + { + Write-Host "PASS $Name" + } + else + { + Write-Host "FAIL $Name (exit code $($ExitCodes[$Name]))" + $AnyFailed = $true + } +} + +if ($AnyFailed) +{ + exit 1 +} +exit 0 diff --git a/Tests/ScriptMessage.Module.Tests.ps1 b/Tests/ScriptMessage.Module.Tests.ps1 new file mode 100644 index 0000000..364e491 --- /dev/null +++ b/Tests/ScriptMessage.Module.Tests.ps1 @@ -0,0 +1,28 @@ +# Pester 5 or later. Run .\Tests\Invoke-Tests.ps1 to test under both PowerShell editions, or +# Invoke-Pester -Path .\Tests for the current one. Nothing here connects to a tenant. + +BeforeAll { + $ModuleRoot = Join-Path $PSScriptRoot '..\ScriptMessage' + $ManifestPath = Join-Path $ModuleRoot 'ScriptMessage.psd1' +} + +Describe 'ScriptMessage module' { + It 'has a valid module manifest' { + { Test-ModuleManifest -Path $ManifestPath -ErrorAction Stop } | Should -Not -Throw + } + + It 'exports exactly the functions defined in Public' { + $PublicFunctions = @(Get-ChildItem -Path (Join-Path $ModuleRoot 'Public') -Filter '*.ps1' | ForEach-Object { $_.BaseName } | Sort-Object) + $ExportedFunctions = @((Import-PowerShellDataFile -Path $ManifestPath).FunctionsToExport | Sort-Object) + + ($ExportedFunctions -join ', ') | Should -Be ($PublicFunctions -join ', ') + } + + # PSScriptAnalyzer is optional locally. The CI workflow installs it for PowerShell 7, so the check always runs + # there. + It 'has no PSScriptAnalyzer errors' -Skip:(-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { + $Findings = @(Invoke-ScriptAnalyzer -Path $ModuleRoot -Recurse -Severity Error) + + @($Findings | ForEach-Object { '{0}:{1} {2}' -f $_.ScriptName, $_.Line, $_.RuleName }) | Should -BeNullOrEmpty + } +} diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 new file mode 100644 index 0000000..7ec8701 --- /dev/null +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -0,0 +1,307 @@ +# Pester 5 or later. Run from the repository root: +# Invoke-Pester -Path .\Tests +# Nothing here connects to a tenant, and the Microsoft Graph modules do not need to be installed. + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..\ScriptMessage\ScriptMessage.psd1') -Force + + # Pester can only mock a command that exists, so define stand-ins for the Microsoft Graph cmdlets inside the + # module before mocking them. Each throws if a test reaches it without a mock. + InModuleScope ScriptMessage { + function script:Send-MgUserMail { param($UserId, $BodyParameter, [switch]$PassThru) throw 'Send-MgUserMail stand-in called without a mock.' } + function script:Disconnect-MgGraph { throw 'Disconnect-MgGraph stand-in called without a mock.' } + function script:New-MgChat { param($ChatType, $Members) throw 'New-MgChat stand-in called without a mock.' } + function script:New-MgChatMessage { param($ChatId, $BodyParameter) throw 'New-MgChatMessage stand-in called without a mock.' } + function script:Get-MgChat { param([switch]$All, $Filter, $Property, $ExpandProperty) throw 'Get-MgChat stand-in called without a mock.' } + function script:Get-MgUserDrive { param($UserId) throw 'Get-MgUserDrive stand-in called without a mock.' } + function script:Get-MgDriveItem { param($DriveId, $DriveItemId, $ExpandProperty) throw 'Get-MgDriveItem stand-in called without a mock.' } + function script:Invoke-MgGraphRequest { param($Method, $Uri, $Body, $ContentType) throw 'Invoke-MgGraphRequest stand-in called without a mock.' } + function script:Invoke-MgInviteDriveItem { param($DriveId, $DriveItemId, $BodyParameter) throw 'Invoke-MgInviteDriveItem stand-in called without a mock.' } + } +} + +AfterAll { + Remove-Module ScriptMessage -Force -ErrorAction SilentlyContinue +} + +Describe 'Send-ScriptMessage recipient check' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + MailType = 'Group' + ChatType = 'Group' + IncludeBCCInGroupChat = $false + MgPermissionType = 'Application' + MgDisconnectWhenDone = $false + } + } + # MicrosoftGraph is enum value 0, which is false, so compare with $null rather than testing $Service. + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + Mock -ModuleName ScriptMessage Connect-ScriptMessage { } + Mock -ModuleName ScriptMessage Send-MgUserMail { $true } + + $MessageArguments = @{ + Service = 'MicrosoftGraph' + Type = 'Mail' + From = 'sender@example.org' + Subject = 'Test subject' + Body = 'Test body' + } + } + + Context 'Recipients with an address are accepted' { + It 'sends to ' -ForEach @( + @{ Name = 'a one-item array holding a ConvertFrom-Json object'; ExpectedType = 'Object[]'; Expected = 'a@example.org' + To = @((ConvertFrom-Json '{"Name":"A","Address":"a@example.org"}')) } + @{ Name = 'a one-item JSON array property'; ExpectedType = 'Object[]'; Expected = 'a@example.org' + To = (ConvertFrom-Json '{"To":[{"Name":"A","Address":"a@example.org"}]}').To } + @{ Name = 'a single object'; ExpectedType = 'PSCustomObject'; Expected = 'a@example.org' + To = [pscustomobject]@{ Name = 'A'; Address = 'a@example.org' } } + @{ Name = 'two objects'; ExpectedType = 'Object[]'; Expected = 'a@example.org,b@example.org' + To = @([pscustomobject]@{ Name = 'A'; Address = 'a@example.org' }, [pscustomobject]@{ Name = 'B'; Address = 'b@example.org' }) } + @{ Name = 'a hashtable'; ExpectedType = 'Hashtable'; Expected = 'a@example.org' + To = @{ Name = 'A'; Address = 'a@example.org' } } + @{ Name = 'a string'; ExpectedType = 'String'; Expected = 'a@example.org' + To = 'a@example.org' } + @{ Name = 'two strings'; ExpectedType = 'Object[]'; Expected = 'a@example.org,b@example.org' + To = @('a@example.org', 'b@example.org') } + ) { + # Confirms the test data kept its shape; an unrolled one-item array would no longer test that case. + $To.GetType().Name | Should -Be $ExpectedType + + $null = Send-ScriptMessage @MessageArguments -To $To + + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly -ParameterFilter { + (@($BodyParameter.Message.ToRecipients | ForEach-Object { $_.EmailAddress.Address }) -join ',') -eq $Expected + } + } + + It 'sends to a one-item array in CC when To is not given' { + $CC = @((ConvertFrom-Json '{"Name":"C","Address":"c@example.org"}')) + + $null = Send-ScriptMessage @MessageArguments -CC $CC + + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly -ParameterFilter { + (@($BodyParameter.Message.CcRecipients | ForEach-Object { $_.EmailAddress.Address }) -join ',') -eq 'c@example.org' + } + } + } + + Context 'Recipients without an address are rejected before connecting' { + It 'throws for ' -ForEach @( + @{ Name = 'an empty string'; To = '' } + @{ Name = 'a whitespace string'; To = ' ' } + @{ Name = 'an object with a blank Address'; To = [pscustomobject]@{ Name = 'A'; Address = '' } } + @{ Name = 'two objects with blank Addresses'; To = @([pscustomobject]@{ Name = 'A'; Address = '' }, [pscustomobject]@{ Name = 'B'; Address = ' ' }) } + @{ Name = 'a hashtable with a blank Address'; To = @{ Name = 'A'; Address = '' } } + ) { + { Send-ScriptMessage @MessageArguments -To $To } | Should -Throw '*at least one parameter value*' + Should -Invoke -ModuleName ScriptMessage Connect-ScriptMessage -Times 0 -Exactly + } + + It 'throws when no To, CC, or BCC is given' { + { Send-ScriptMessage @MessageArguments } | Should -Throw '*at least one parameter value*' + Should -Invoke -ModuleName ScriptMessage Connect-ScriptMessage -Times 0 -Exactly + } + } +} + +Describe 'Send-ScriptMessage chat message' { + BeforeAll { + Mock -ModuleName ScriptMessage Connect-ScriptMessage { } + Mock -ModuleName ScriptMessage Get-ScriptMessageContext { + [pscustomobject]@{ + Account = 'sender@example.org' + Scopes = @('Chat.ReadWrite') + } + } + Mock -ModuleName ScriptMessage New-MgChat { [pscustomobject]@{ Id = 'new-chat-id' } } + Mock -ModuleName ScriptMessage New-MgChatMessage { [pscustomobject]@{ Id = 'new-message-id' } } + Mock -ModuleName ScriptMessage Get-MgChat { @() } + Mock -ModuleName ScriptMessage Send-MgUserMail { $true } + + $MessageArguments = @{ + Service = 'MicrosoftGraph' + Type = 'Chat' + From = 'sender@example.org' + To = 'recipient@example.org' + Subject = 'Test subject' + Body = 'Test body' + } + } + + # Each context sets ChatType in the configuration rather than passing -ChatType, because the configuration + # value currently wins over an explicitly passed parameter. + Context 'A one-on-one chat with no attachments' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail', 'Chat') + MailType = 'Group' + ChatType = 'OneOnOne' + IncludeBCCInGroupChat = $false + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + # MicrosoftGraph is enum value 0, which is false, so compare with $null rather than testing $Service. + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'sends the message body and reports no error' { + $Result = Send-ScriptMessage @MessageArguments + + $Result.Error | Should -BeNullOrEmpty + $Result.Status.Id | Should -Be 'new-message-id' + Should -Invoke -ModuleName ScriptMessage New-MgChatMessage -Times 1 -Exactly -ParameterFilter { + $BodyParameter.Body.Content -eq 'Test body' + } + } + } + + Context 'A group chat with no attachments' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail', 'Chat') + MailType = 'Group' + ChatType = 'Group' + IncludeBCCInGroupChat = $false + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'sends the message body and reports no error' { + $Result = Send-ScriptMessage @MessageArguments + + $Result.Error | Should -BeNullOrEmpty + $Result.Status.Id | Should -Be 'new-message-id' + Should -Invoke -ModuleName ScriptMessage New-MgChatMessage -Times 1 -Exactly -ParameterFilter { + $BodyParameter.Body.Content -eq 'Test body' + } + } + } + + Context 'Mail and Chat in the same call' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail', 'Chat') + MailType = 'Group' + ChatType = 'OneOnOne' + IncludeBCCInGroupChat = $false + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + } + + It 'sends both, and the chat carries its own message content' { + $Arguments = $MessageArguments.Clone() + $Arguments['Type'] = @('Mail', 'Chat') + + $Result = @(Send-ScriptMessage @Arguments) + + @($Result | Where-Object { $_.MessageType -eq 'Mail' }).Count | Should -Be 1 + @($Result | Where-Object { $_.MessageType -eq 'Chat' }).Count | Should -Be 1 + # One result comes back per message type, so drop the empty Error properties instead of testing the + # array of them, which is neither null nor empty. + @($Result.Error | Where-Object { $null -ne $_ }) | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage New-MgChatMessage -Times 1 -Exactly -ParameterFilter { + $BodyParameter.Body.Content -eq 'Test body' + } + } + } + + Context 'A one-on-one chat with an attachment' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail', 'Chat') + MailType = 'Group' + ChatType = 'OneOnOne' + IncludeBCCInGroupChat = $false + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + Mock -ModuleName ScriptMessage Get-MgUserDrive { [pscustomobject]@{ Id = 'drive-id' } } + Mock -ModuleName ScriptMessage Get-MgDriveItem { $null } # The chat files folder does not exist yet. + Mock -ModuleName ScriptMessage Invoke-MgGraphRequest { + # Graph returns the uploaded drive item as a hashtable, which the attachment converter reads by key. + @{ id = 'drive-item-id'; name = 'My File+1.pdf'; webUrl = 'https://www.domain.com/file' } + } + Mock -ModuleName ScriptMessage Invoke-MgInviteDriveItem { @{ id = 'invite-id' } } + } + + It 'escapes the attachment filename for the upload path' { + $Arguments = $MessageArguments.Clone() + $Arguments['Attachment'] = @{ Name = 'My File+1.pdf'; Content = [byte[]]@(1, 2, 3) } + + $Result = Send-ScriptMessage @Arguments + + $Result.Error | Should -BeNullOrEmpty + # A space has to arrive as %20. In a URL path a '+' is a literal plus sign, not a space. + Should -Invoke -ModuleName ScriptMessage Invoke-MgGraphRequest -Times 1 -Exactly -ParameterFilter { + $Uri -like '*/root:/Microsoft Teams Chat Files/My%20File%2B1.pdf:/content' + } + } + } +} + +Describe 'Send-ScriptMessage mail attachment' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Mail') + MailType = 'Group' + ChatType = 'Group' + IncludeBCCInGroupChat = $false + MgPermissionType = 'Application' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + Mock -ModuleName ScriptMessage Connect-ScriptMessage { } + Mock -ModuleName ScriptMessage Send-MgUserMail { $true } + + $MailArguments = @{ + Service = 'MicrosoftGraph' + Type = 'Mail' + From = 'sender@example.org' + To = 'recipient@example.org' + Subject = 'Test subject' + Body = 'Test body' + } + } + + It 'sends attachment(s) to Microsoft Graph' -ForEach @( + @{ Count = 1; Files = @(@{ Name = 'One.pdf'; Content = [byte[]]@(1) }) } + @{ Count = 2; Files = @(@{ Name = 'One.pdf'; Content = [byte[]]@(1) }, @{ Name = 'Two.pdf'; Content = [byte[]]@(2) }) } + ) { + $null = Send-ScriptMessage @MailArguments -Attachment $Files + + # An empty Attachments property is $null, and @($null).Count is 1, so filter before counting. + Should -Invoke -ModuleName ScriptMessage Send-MgUserMail -Times 1 -Exactly -ParameterFilter { + @($BodyParameter.Message.Attachments | Where-Object { $null -ne $_ }).Count -eq $Count + } + } +}