Guide

Test Email in CI/CD with PowerShell

Verify that your application sends the right emails by testing delivery end-to-end in CI/CD. Send a test email, wait for it to arrive, read it with Nylas CLI, and assert on content -- all from PowerShell steps in GitHub Actions or Azure DevOps pipelines.

The pattern: send, wait, verify

End-to-end email testing follows a simple loop: trigger an email from your application, poll the target inbox until it arrives, then assert on the content. The key insight is that Nylas CLI gives you programmatic inbox access with JSON output -- exactly what you need for CI/CD assertions.

# The core pattern in PowerShell
# 1. Trigger: your app sends an email (password reset, welcome, invoice, etc.)
Invoke-RestMethod -Uri "https://your-app.com/api/reset-password" `
    -Method Post `
    -Body (@{ email = "test@example.com" } | ConvertTo-Json) `
    -ContentType "application/json"

# 2. Wait: poll inbox until the email arrives
$maxRetries = 30
$found = $null

for ($i = 0; $i -lt $maxRetries; $i++) {
    $emails = nylas email list --unread --json --limit 10 | ConvertFrom-Json
    $found = $emails | Where-Object { $_.subject -match "password reset" }
    if ($found) { break }
    Start-Sleep -Seconds 10
}

if (-not $found) {
    throw "Email did not arrive within timeout"
}

# 3. Verify: assert on content
$detail = nylas email read $found.id --json | ConvertFrom-Json
if ($detail.body -notmatch "https://your-app.com/reset?token=") {
    throw "Reset link not found in email body"
}

Write-Host "Email verified successfully" -ForegroundColor Green

GitHub Actions workflow

GitHub Actions supports PowerShell via the pwsh shell on all runner operating systems (Ubuntu, macOS, Windows). Here is a complete workflow that tests password reset email delivery.

# .github/workflows/email-test.yml
name: Email Delivery Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test-email:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Nylas CLI
        shell: bash
        run: |
          curl -fsSL https://cli.nylas.com/install.sh | bash
          echo "$HOME/.nylas/bin" >> $GITHUB_PATH

      - name: Configure Nylas auth
        shell: pwsh
        env:
          NYLAS_AUTH_TOKEN: ${{ secrets.NYLAS_AUTH_TOKEN }}
        run: |
          # The CLI reads auth from environment or config file
          nylas auth whoami

      - name: Deploy test app
        shell: bash
        run: |
          # Start your application (adjust for your stack)
          docker compose up -d
          sleep 10

      - name: Trigger password reset email
        shell: pwsh
        run: |
          Invoke-RestMethod `
              -Uri "http://localhost:3000/api/reset-password" `
              -Method Post `
              -Body (@{ email = "test-inbox@example.com" } | ConvertTo-Json) `
              -ContentType "application/json"

      - name: Wait for email and verify content
        shell: pwsh
        run: |
          $maxRetries = 30
          $found = $null

          for ($i = 0; $i -lt $maxRetries; $i++) {
              Write-Host "Polling attempt $($i + 1)/$maxRetries..."
              $emails = nylas email list --unread --json --limit 10 |
                  ConvertFrom-Json
              $found = $emails |
                  Where-Object { $_.subject -match "password reset" }
              if ($found) { break }
              Start-Sleep -Seconds 10
          }

          if (-not $found) {
              throw "Password reset email did not arrive within 5 minutes"
          }

          Write-Host "Found email: $($found.subject)" -ForegroundColor Green

          # Read full content
          $detail = nylas email read $found.id --json | ConvertFrom-Json

          # Verify reset link exists
          if ($detail.body -notmatch "https?://[^s]+reset[^s]*token=") {
              throw "Reset link not found in email body"
          }

          Write-Host "Reset link verified" -ForegroundColor Green

Azure DevOps pipeline

Azure DevOps pipelines support PowerShell natively. Use the PowerShell@2 task for inline scripts or reference external .ps1 files.

# azure-pipelines.yml
trigger:
  branches:
    include:
      - main

pool:
  vmImage: "windows-latest"

variables:
  - group: nylas-secrets  # Contains NYLAS_AUTH_TOKEN

steps:
  - task: PowerShell@2
    displayName: "Install Nylas CLI"
    inputs:
      targetType: inline
      script: |
        irm https://cli.nylas.com/install.ps1 | iex

  - task: PowerShell@2
    displayName: "Verify Nylas auth"
    inputs:
      targetType: inline
      script: nylas auth whoami
    env:
      NYLAS_AUTH_TOKEN: $(NYLAS_AUTH_TOKEN)

  - task: PowerShell@2
    displayName: "Send test email"
    inputs:
      targetType: inline
      script: |
        nylas email send `
            --to "test-inbox@example.com" `
            --subject "CI Build $(Build.BuildNumber)" `
            --body "This is an automated test email from build $(Build.BuildNumber)." `
            --yes --json
    env:
      NYLAS_AUTH_TOKEN: $(NYLAS_AUTH_TOKEN)

  - task: PowerShell@2
    displayName: "Verify email delivery"
    inputs:
      targetType: inline
      script: |
        $buildNumber = "$(Build.BuildNumber)"
        $maxRetries = 30
        $found = $null

        for ($i = 0; $i -lt $maxRetries; $i++) {
            Write-Host "Polling attempt $($i + 1)/$maxRetries..."
            $emails = nylas email list --unread --json --limit 20 |
                ConvertFrom-Json
            $found = $emails |
                Where-Object { $_.subject -match $buildNumber }
            if ($found) { break }
            Start-Sleep -Seconds 10
        }

        if (-not $found) {
            Write-Error "Test email did not arrive within timeout"
            exit 1
        }

        Write-Host "##vso[task.complete result=Succeeded;]Email delivered"
    env:
      NYLAS_AUTH_TOKEN: $(NYLAS_AUTH_TOKEN)

Pester test assertions

Pester is PowerShell's testing framework -- the equivalent of Jest or pytest. Use it to write structured assertions on email content with proper test reporting.

# tests/Email.Tests.ps1
BeforeAll {
    # Helper: poll until email arrives or timeout
    function Wait-ForEmail {
        param(
            [string]$SubjectPattern,
            [int]$TimeoutSeconds = 300,
            [int]$IntervalSeconds = 10
        )

        $deadline = (Get-Date).AddSeconds($TimeoutSeconds)

        while ((Get-Date) -lt $deadline) {
            $emails = nylas email list --unread --json --limit 20 |
                ConvertFrom-Json
            $match = $emails |
                Where-Object { $_.subject -match $SubjectPattern }
            if ($match) { return $match }
            Start-Sleep -Seconds $IntervalSeconds
        }

        return $null
    }
}

Describe "Password Reset Email" {
    BeforeAll {
        # Trigger password reset
        Invoke-RestMethod `
            -Uri "http://localhost:3000/api/reset-password" `
            -Method Post `
            -Body (@{ email = "test@example.com" } | ConvertTo-Json) `
            -ContentType "application/json"

        # Wait for it
        $script:email = Wait-ForEmail -SubjectPattern "password reset"
        if ($script:email) {
            $script:detail = nylas email read $script:email.id --json |
                ConvertFrom-Json
        }
    }

    It "should arrive within 5 minutes" {
        $script:email | Should -Not -BeNullOrEmpty
    }

    It "should have the correct subject" {
        $script:email.subject | Should -Match "Reset your password"
    }

    It "should be from the no-reply address" {
        $script:email.from | Should -Match "no-reply@"
    }

    It "should contain a reset link" {
        $script:detail.body | Should -Match "https?://[^s]+/reset?token="
    }

    It "should not contain sensitive data" {
        $script:detail.body | Should -Not -Match "password"
        $script:detail.body | Should -Not -Match "secret"
    }

    It "should have an unsubscribe header" {
        $script:detail.headers."List-Unsubscribe" |
            Should -Not -BeNullOrEmpty
    }
}

Describe "Welcome Email" {
    BeforeAll {
        Invoke-RestMethod `
            -Uri "http://localhost:3000/api/register" `
            -Method Post `
            -Body (@{
                email = "test@example.com"
                name = "Test User"
            } | ConvertTo-Json) `
            -ContentType "application/json"

        $script:email = Wait-ForEmail -SubjectPattern "welcome"
        if ($script:email) {
            $script:detail = nylas email read $script:email.id --json |
                ConvertFrom-Json
        }
    }

    It "should arrive within 5 minutes" {
        $script:email | Should -Not -BeNullOrEmpty
    }

    It "should greet the user by name" {
        $script:detail.body | Should -Match "Test User"
    }

    It "should include a getting started link" {
        $script:detail.body | Should -Match "getting.started"
    }
}

Run the tests locally or in CI:

# Run Pester tests
Invoke-Pester -Path ./tests/Email.Tests.ps1 -Output Detailed

# Generate JUnit XML for CI reporting
Invoke-Pester -Path ./tests/Email.Tests.ps1 `
    -OutputFile ./test-results.xml `
    -OutputFormat JUnitXml

Test OTP verification flows

The most common CI/CD email test: trigger a password reset or signup, extract the OTP code, and use it to complete the flow. The nylas otp get command extracts verification codes automatically.

# test-otp-flow.ps1 -- Full password reset E2E test
# 1. Trigger password reset
Invoke-RestMethod `
    -Uri "http://localhost:3000/api/reset-password" `
    -Method Post `
    -Body (@{ email = "test@example.com" } | ConvertTo-Json) `
    -ContentType "application/json"

# 2. Wait for OTP code
Start-Sleep -Seconds 5
$code = $null

for ($i = 0; $i -lt 30; $i++) {
    $code = nylas otp get --raw
    if ($code) { break }
    Start-Sleep -Seconds 10
}

if (-not $code) {
    throw "OTP code did not arrive"
}

Write-Host "Got OTP code: $code" -ForegroundColor Green

# 3. Submit the OTP code to complete the reset
$response = Invoke-RestMethod `
    -Uri "http://localhost:3000/api/verify-reset" `
    -Method Post `
    -Body (@{
        email = "test@example.com"
        code = $code
        newPassword = "NewSecureP@ss123"
    } | ConvertTo-Json) `
    -ContentType "application/json"

if ($response.success -ne $true) {
    throw "OTP verification failed: $($response.error)"
}

Write-Host "Password reset completed successfully" -ForegroundColor Green

When your app sends a reset link instead of an OTP code, extract the URL from the email body and verify it works.

# test-reset-link.ps1 -- Extract reset link and verify it loads
# 1. Trigger password reset
Invoke-RestMethod `
    -Uri "http://localhost:3000/api/reset-password" `
    -Method Post `
    -Body (@{ email = "test@example.com" } | ConvertTo-Json) `
    -ContentType "application/json"

# 2. Wait for and read the email
$found = $null

for ($i = 0; $i -lt 30; $i++) {
    $emails = nylas email list --unread --json --limit 10 | ConvertFrom-Json
    $found = $emails | Where-Object { $_.subject -match "reset" }
    if ($found) { break }
    Start-Sleep -Seconds 10
}

if (-not $found) { throw "Reset email did not arrive" }

$detail = nylas email read $found.id --json | ConvertFrom-Json

# 3. Extract the reset link
if ($detail.body -match '(https?://[^s"<]+reset[^s"<]*token=[^s"<]+)') {
    $resetLink = $matches[1]
    Write-Host "Found reset link: $resetLink" -ForegroundColor Green
} else {
    throw "No reset link found in email body"
}

# 4. Verify the link returns 200
$response = Invoke-WebRequest -Uri $resetLink -UseBasicParsing
if ($response.StatusCode -ne 200) {
    throw "Reset link returned status $($response.StatusCode)"
}

Write-Host "Reset link is valid (HTTP 200)" -ForegroundColor Green

Check for bounces in CI

After sending test emails, verify none bounced. This catches misconfigured SMTP, SPF failures, or invalid recipient addresses.

# check-bounces.ps1 -- Verify no emails bounced
$emails = nylas email list --json --limit 50 | ConvertFrom-Json

$bounces = $emails | Where-Object {
    $_.from -match "mailer-daemon|postmaster" -or
    $_.subject -match "delivery.*fail|undeliverable|bounced"
}

if ($bounces) {
    Write-Host "BOUNCES DETECTED:" -ForegroundColor Red
    foreach ($bounce in $bounces) {
        Write-Host "  Subject: $($bounce.subject)" -ForegroundColor Red
        Write-Host "  From: $($bounce.from)" -ForegroundColor DarkRed
    }
    throw "Found $($bounces.Count) bounced email(s)"
}

Write-Host "No bounces detected" -ForegroundColor Green

GitHub Actions with Pester reporting

Combine everything into a workflow that runs Pester tests and publishes results as a GitHub check.

# .github/workflows/email-pester.yml
name: Email Tests (Pester)

on:
  push:
    branches: [main]

jobs:
  email-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Nylas CLI
        shell: bash
        run: |
          curl -fsSL https://cli.nylas.com/install.sh | bash
          echo "$HOME/.nylas/bin" >> $GITHUB_PATH

      - name: Start test app
        shell: bash
        run: docker compose up -d && sleep 10

      - name: Run Pester email tests
        shell: pwsh
        env:
          NYLAS_AUTH_TOKEN: ${{ secrets.NYLAS_AUTH_TOKEN }}
        run: |
          Install-Module -Name Pester -Force -Scope CurrentUser
          $config = New-PesterConfiguration
          $config.TestResult.Enabled = $true
          $config.TestResult.OutputPath = "test-results.xml"
          $config.TestResult.OutputFormat = "JUnitXml"
          $config.Output.Verbosity = "Detailed"
          Invoke-Pester -Configuration $config

      - name: Publish test results
        uses: dorny/test-reporter@v1
        if: always()
        with:
          name: Email Tests
          path: test-results.xml
          reporter: java-junit

Frequently asked questions

Can I test email delivery in CI/CD without a real mailbox?

You need a real mailbox to verify end-to-end delivery. Nylas CLI authenticates with Gmail, Outlook, or any IMAP provider. Use a dedicated test account. The CLI handles OAuth tokens -- store the refresh token as a CI/CD secret and the CLI will re-authenticate automatically.

How do I wait for an email in a CI/CD pipeline?

Use a PowerShell polling loop: call nylas email list --json repeatedly with Start-Sleep between attempts. Set a maximum retry count (e.g., 30 retries at 10 seconds = 5 minutes). If the email does not arrive within the timeout, fail the test. For OTP codes specifically, use nylas otp watch which blocks until a code arrives.

Does Nylas CLI work in GitHub Actions runners?

Yes. Nylas CLI is a standalone Go binary available for Linux, macOS, and Windows. In GitHub Actions, install it in a setup step, then call it from pwsh steps. Store your Nylas auth token as a repository secret.

Can I use Pester to assert on email content?

Yes. Call Nylas CLI to fetch the email, parse the JSON with ConvertFrom-Json, then use Pester's Should assertions to verify subject, sender, body content, and attachments. Pester integrates with both GitHub Actions and Azure DevOps test reporting.


Next steps