Send Emails from Azure DevOps Pipelines: Build a Custom SendGrid Extension

Introduction

Ever wanted your Azure DevOps pipeline to send a beautifully formatted email the moment a build finishes, a release deploys, or a test run completes? While Azure DevOps has built-in notifications, they are limited in formatting and flexibility. In this post, we’ll build a custom Azure DevOps pipeline extension that sends emails via SendGrid — a task you can drop into any pipeline, share with your whole organization, and even publish to the Visual Studio Marketplace.

By the end, you’ll have a reusable “Send Email via SendGrid” task with configurable recipients, subject, and HTML body — with the API key kept safely out of your code.

What You'll Need

  1. SendGrid Account: Sign up at SendGrid and create an API key with Mail Send permission. It will look like SG.**************** — keep it secret; we’ll never hardcode it.
  2. Node.js: The task handler runs on Node. Install the latest LTS version.
  3. tfx-cli: The cross-platform CLI for packaging Azure DevOps extensions:
    npm install -g tfx-cli
  4. Marketplace Publisher Account: Create one at the Visual Studio Marketplace publisher portal — you’ll get a publisher ID (shown below as {your-publisher-id}).
⚠️ Security First: Throughout this post, values like {your-publisher-id}, {your-organisation}, and SG.**************** are placeholders. Never commit your real SendGrid API key to source control, paste it into a blog, or bake it into the extension package — we’ll pass it in as a secret pipeline variable at runtime instead.

Project Structure

An Azure DevOps extension is just a folder with two manifests and your task code:

SendGridEmailExtension/
├── vss-extension.json      ← extension manifest (marketplace listing)
├── icon.png                ← 128x128 extension icon
└── SendGridEmailTask/
    ├── task.json           ← task definition (inputs, UI)
    ├── index.js            ← the code that sends the email
    └── package.json        ← node dependencies

Step 1: Define the Task — task.json

The task.json declares your task’s identity and the inputs users will see in the pipeline UI:

{
    "id": "{generate-a-new-guid}",
    "name": "SendGridEmail",
    "friendlyName": "Send Email via SendGrid",
    "description": "Sends an email using the SendGrid API",
    "category": "Utility",
    "author": "{your-publisher-name}",
    "version": { "Major": 1, "Minor": 0, "Patch": 0 },
    "instanceNameFormat": "Send email to $(toAddress)",
    "inputs": [
        {
            "name": "apiKey",
            "type": "string",
            "label": "SendGrid API Key",
            "required": true,
            "helpMarkDown": "Pass a secret variable, e.g. $(SendGridApiKey)"
        },
        {
            "name": "fromAddress",
            "type": "string",
            "label": "From Address",
            "required": true
        },
        {
            "name": "toAddress",
            "type": "string",
            "label": "To Address(es)",
            "required": true,
            "helpMarkDown": "Separate multiple recipients with semicolons"
        },
        {
            "name": "subject",
            "type": "string",
            "label": "Subject",
            "required": true
        },
        {
            "name": "body",
            "type": "multiLine",
            "label": "Email Body (HTML supported)",
            "required": true
        }
    ],
    "execution": {
        "Node20_1": { "target": "index.js" }
    }
}
💡 Tip: The id must be a fresh GUID unique to your task — generate one with [guid]::NewGuid() in PowerShell. Don’t reuse a GUID from a sample you found online.

Step 2: Write the Task Logic — index.js

The handler reads the inputs, calls the SendGrid API, and reports success or failure back to the pipeline:

const tl = require('azure-pipelines-task-lib/task');
const sgMail = require('@sendgrid/mail');

async function run() {
    try {
        // Read inputs defined in task.json
        const apiKey  = tl.getInput('apiKey', true);
        const from    = tl.getInput('fromAddress', true);
        const to      = tl.getInput('toAddress', true).split(';');
        const subject = tl.getInput('subject', true);
        const body    = tl.getInput('body', true);

        // Never log the API key — mark it as a secret
        tl.setSecret(apiKey);

        sgMail.setApiKey(apiKey);

        await sgMail.send({
            to: to,
            from: from,
            subject: subject,
            html: body
        });

        console.log(`Email sent successfully to ${to.join(', ')}`);
        tl.setResult(tl.TaskResult.Succeeded, 'Email sent');
    }
    catch (err) {
        // SendGrid returns detailed errors in err.response.body
        const details = err.response?.body
            ? JSON.stringify(err.response.body)
            : err.message;
        tl.setResult(tl.TaskResult.Failed, `Send failed: ${details}`);
    }
}

run();

Install the two dependencies inside the task folder:

cd SendGridEmailTask
npm install azure-pipelines-task-lib @sendgrid/mail
🔐 Secret Handling: The call to tl.setSecret(apiKey) tells the pipeline agent to mask the key with *** in all logs — even if some library accidentally prints it. Combined with passing the key as a secret variable, your key never appears anywhere in plain text.

Step 3: The Extension Manifest — vss-extension.json

{
    "manifestVersion": 1,
    "id": "sendgrid-email-task",
    "name": "SendGrid Email Sender",
    "version": "1.0.0",
    "publisher": "{your-publisher-id}",
    "targets": [ { "id": "Microsoft.VisualStudio.Services" } ],
    "description": "Send emails from your pipelines using SendGrid.",
    "categories": [ "Azure Pipelines" ],
    "icons": { "default": "icon.png" },
    "files": [ { "path": "SendGridEmailTask" } ],
    "contributions": [
        {
            "id": "sendgrid-email-task",
            "type": "ms.vss-distributed-task.task",
            "targets": [ "ms.vss-distributed-task.tasks" ],
            "properties": { "name": "SendGridEmailTask" }
        }
    ]
}

Step 4: Package and Publish

From the extension root folder, create the .vsix package:

tfx extension create --manifest-globs vss-extension.json

Then upload the generated .vsix in the Visual Studio Marketplace publisher portal. For an internal-only task, keep the extension private and share it with your organisation ({your-organisation}) — it will then appear under Organization Settings → Extensions for installation. Only make it public if you intend the whole world to use it.

Step 5: Use It in a Pipeline

First, add your SendGrid API key as a secret variable: in your pipeline, go to Variables → New variable, name it SendGridApiKey, paste the key, and tick “Keep this value secret”. (For reuse across pipelines, put it in a Variable Group — or better yet, link the group to Azure Key Vault.)

Then call the task from YAML:

steps:
- task: SendGridEmail@1
  displayName: 'Notify team of deployment'
  inputs:
    apiKey: '$(SendGridApiKey)'   # secret variable — never the raw key
    fromAddress: 'builds@yourdomain.com'
    toAddress: 'team@yourdomain.com;qa@yourdomain.com'
    subject: '✅ $(Build.DefinitionName) #$(Build.BuildNumber) deployed'
    body: '<h2>Deployment succeeded</h2><p>Branch: $(Build.SourceBranchName)</p>'
✅ Result: Run the pipeline and your inbox receives a formatted HTML email with live build variables — and if you check the pipeline logs, the API key shows up only as ***.

Security Checklist

  • 🔐 API key passed only via a secret variable or Key Vault — never in YAML, code, or the extension package.
  • 🚫 tl.setSecret() masks the key in agent logs.
  • 🔒 Extension kept private and shared only with your organisation unless intended for public use.
  • 🔁 Rotate the SendGrid key periodically, and scope it to Mail Send only — a leaked full-access key is a much bigger problem.

Conclusion

With about a hundred lines of JSON and JavaScript, you now have a professional, reusable pipeline task that sends rich HTML emails through SendGrid — installable across every project in your organisation with two clicks. The same extension skeleton (manifest + task.json + Node handler) is the foundation for any custom pipeline task you can imagine: posting to Teams, calling internal APIs, or gating deployments.

Happy coding, and may your pipelines always email you good news!

Comments

Popular posts from this blog

Create Release to Run Tests on Azure DevOps Release Pipeline via C#