This tutorial walks you through adding a new notification sender — an email address like newemployees@mckimcreed.com that shows a specific name (e.g. "New Employee(s)") when it lands in someone's inbox.
Our notification system already has an app registration (Notify-NoReply) that is allowed to send mail on behalf of any mailbox in the svc-notify-senders security group. So adding a new sender is really two things: create a shared mailbox with the name you want, then add it to that group. No changes to the app registration, no code changes to the auth side.
> Why a separate mailbox per name? Exchange Online always stamps the outgoing "From" name with the mailbox's own Display name — it ignores whatever name an app tries to set per message. So each distinct sender name needs its own shared mailbox. For the full background, see Sending Notification Email as a Shared Mailbox.
Prerequisites
- Exchange Administrator (or Global Administrator) role in Microsoft 365
- The display name you want recipients to see (e.g.
New Employee(s)) - The email address for the new sender (e.g.
newemployees@mckimcreed.com) - The name of the target group —
svc-notify-senders— and the app registration it serves — Notify-NoReply
> Shared mailboxes are free (no license required) and have sign-in disabled by default — there is no password to manage.
---
Manual steps (admin center)
Step 1: Create the shared mailbox
- Go to the Exchange admin center → admin.exchange.microsoft.com
- In the left menu, select Recipients → Mailboxes
- Click Add a shared mailbox
- Fill in:
-
Display name — the name recipients will see, e.g.
New Employee(s)(this is the important field — it becomes the "From" name on every message this sender sends) -
Email address — e.g.
newemployees@mckimcreed.com
- Click Create
- Go into properties and hide mailbox
Step 2: Open the svc-notify-senders group
- In the Exchange admin center, go to Recipients → Groups
- In the Mail-Enabled security list, find and click svc-notify-senders
- Select the Members tab
Step 3: Add the new mailbox to the group
- On the Members tab, click Add members
- Search for the mailbox you created in Step 1 (e.g.
New Employee(s)/newemployees@mckimcreed.com) - Check the box next to it and click Add
- Confirm the member now appears in the list
This is the step that authorizes the Notify-NoReply app to send as the new address — because the app is scoped to this group's membership.
Step 4: Wait for propagation
Microsoft 365 needs time to apply the new group membership across Exchange Online. Allow up to ~60 minutes before the app can reliably send from the new address. During this window, sends from the new address may be rejected — that's expected.
Step 5: Verify and hand off
Once propagation has completed:
- Test a send — ask the developer/service owner to send a test notification from the new address, or run the verification in the PowerShell section below.
- Confirm the name — the test email should arrive showing your Display name from Step 1 (e.g. "New Employee(s)").
-
Give the address to the service owner — they set the new sender address (e.g.
newemployees@mckimcreed.com) as the "From" address in their application's configuration.
Done
The new notification address is live. Any message the service sends from it will display the name you configured, while all of it continues to route through the single Notify-NoReply app registration.
---
Doing the same thing with PowerShell
If you prefer PowerShell (or want to script adding several senders at once), the steps below accomplish exactly the same result as the manual walkthrough above.
1. Connect to Exchange Online
Install-Module ExchangeOnlineManagement -Scope CurrentUser # first time only
Connect-ExchangeOnline -UserPrincipalName you@mckimcreed.com
2. Create the shared mailbox
Set -DisplayName to the name recipients should see, and -PrimarySmtpAddress to the new address.
$name = "New Employee(s)"
$email = "newemployees@mckimcreed.com"
New-Mailbox -Shared -Name $name -DisplayName $name -PrimarySmtpAddress $email
3. Add it to the svc-notify-senders group
Add-DistributionGroupMember -Identity svc-notify-senders -Member $email
4. Verify the app can send as it
Allow ~60 minutes for propagation, then confirm the Notify-NoReply app is permitted to send as the new address. Replace with the Notify-NoReply Application (client) ID.
Test-ApplicationAccessPolicy -Identity newemployees@mckimcreed.com -AppId <client-id>
# Expect: AccessCheckResult = Granted
Adding several at once
$senders = @(
@{ Name = "New Employee(s)"; Address = "newemployees@mckimcreed.com" }
@{ Name = "RFQ Monkey"; Address = "noreply-rfq@mckimcreed.com" }
@{ Name = "Purchase Orders"; Address = "noreply-po@mckimcreed.com" }
)
foreach ($s in $senders) {
New-Mailbox -Shared -Name $s.Name -DisplayName $s.Name -PrimarySmtpAddress $s.Address
Add-DistributionGroupMember -Identity svc-notify-senders -Member $s.Address
}
---
Send the mail
The code below sends from the shared mailbox. Note the display name shown to recipients comes from the mailbox's DisplayName, not the Name you pass here — Exchange overwrites it (see Getting a different name per service if you need names to vary).
C# (Microsoft Graph, app-only — recommended)
// dotnet add package Azure.Identity
// dotnet add package Microsoft.Graph
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
// Prefer CertificateCredential(tenantId, clientId, cert) in production
var graph = new GraphServiceClient(credential);
await graph.Users["noreply@mckimcreed.com"].SendMail.PostAsync(new()
{
Message = new Message
{
From = new Recipient { EmailAddress = new()
{ Name = "New Employee(s)", Address = "noreply@mckimcreed.com" } }, // per-service name
Subject = "Your estimate is ready",
Body = new ItemBody { ContentType = BodyType.Html, Content = "<p>…</p>" },
ToRecipients = [ new() { EmailAddress = new() { Address = "user@example.com" } } ]
},
SaveToSentItems = false
});
Config to store (Key Vault / env vars, never in code): TenantId, ClientId, ClientSecret-or-Cert, and the fixed sender noreply@mckimcreed.com. The display name is passed per call by each service.
React / TypeScript (Next.js or Vite)
> Read this first. This is an app-only (client credentials) flow — it needs the client secret, so it can only ever run on a server. Never put the tenant ID / client ID / secret in React component code, in a NEXT_PUBLIC_ variable, or in a VITE_ variable — anything with those prefixes is compiled straight into the JavaScript bundle the browser downloads, i.e. published to the internet. Sending mail is a server action; the React side just posts to your own endpoint.
Next.js (App Router) — server-side Route Handler
Install the same two packages the C# example uses, in their JS form:
bun add @azure/identity @microsoft/microsoft-graph-client isomorphic-fetch
.env.local (never committed — in production use Key Vault or App Service app settings):
AZURE_TENANT_ID=<tenant-id>
AZURE_CLIENT_ID=<client-id>
AZURE_CLIENT_SECRET=<secret>
NOTIFY_FROM_ADDRESS=noreply@mckimcreed.com
lib/graphMailer.ts — a small reusable sender:
import "isomorphic-fetch";
import { ClientSecretCredential } from "@azure/identity";
import { Client } from "@microsoft/microsoft-graph-client";
import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials";
const credential = new ClientSecretCredential(
process.env.AZURE_TENANT_ID!,
process.env.AZURE_CLIENT_ID!,
process.env.AZURE_CLIENT_SECRET!,
);
const graph = Client.initWithMiddleware({
authProvider: new TokenCredentialAuthenticationProvider(credential, {
scopes: ["https://graph.microsoft.com/.default"],
}),
});
const from = process.env.NOTIFY_FROM_ADDRESS!;
export async function sendNotification(opts: {
to: string[];
subject: string;
html: string;
displayName: string; // per-service name, e.g. "New Employee(s)"
}) {
await graph.api(`/users/${from}/sendMail`).post({
message: {
from: { emailAddress: { name: opts.displayName, address: from } },
subject: opts.subject,
body: { contentType: "HTML", content: opts.html },
toRecipients: opts.to.map((address) => ({ emailAddress: { address } })),
},
saveToSentItems: false,
});
}
app/api/notify/route.ts — the endpoint your UI calls:
import { NextResponse } from "next/server";
import { sendNotification } from "@/lib/graphMailer";
export const runtime = "nodejs"; // required — @azure/identity does not run on the Edge runtime
export async function POST(req: Request) {
// Authenticate/authorize the caller here first — this endpoint can send mail
// as the company, so it must never be open to anonymous requests.
const { to, subject, html } = await req.json();
await sendNotification({
to,
subject,
html,
displayName: "New Employee(s)",
});
return NextResponse.json({ ok: true });
}
Calling it from a client component:
"use client";
import { useState } from "react";
export function NotifyButton() {
const [sending, setSending] = useState(false);
async function send() {
setSending(true);
try {
const res = await fetch("/api/notify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: ["user@example.com"],
subject: "Your estimate is ready",
html: "<p>…</p>",
}),
});
if (!res.ok) throw new Error(await res.text());
} finally {
setSending(false);
}
}
return (
<button onClick={send} disabled={sending}>
{sending ? "Sending…" : "Send notification"}
</button>
);
}
A Server Action works just as well if you'd rather skip the route handler — mark the function "use server" and call sendNotification(...) directly inside it. Same rule applies: the credential only ever exists on the server.
Vite + React (SPA) — call a backend, not Graph
A Vite build is 100% browser code, so there is no server to hold the secret. The SPA must call an API you own — an ASP.NET Core endpoint (the C# code above), a Next.js route handler, or an Azure Function — and that API does the Graph call.
// src/api/notify.ts
const API_BASE = import.meta.env.VITE_API_BASE_URL; // a URL is fine to expose; a secret is not
export async function sendNotification(payload: {
to: string[];
subject: string;
html: string;
}) {
const res = await fetch(`${API_BASE}/api/notify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", // or an Authorization header from MSAL
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Notification failed: ${res.status}`);
}
Things to get right on the backend side: CORS must allow your SPA's origin, the endpoint needs its own authentication (MSAL bearer token or session cookie), and the display name is decided server-side so a browser can't spoof which sender it's mailing as.
> Whichever framework you use, the display name still has to match a shared mailbox that exists and is a member of svc-notify-senders — Exchange overwrites the name field with the mailbox's own Display name, exactly as described at the top of this section.
PowerShell smoke test (Graph REST)
$tenantId = "<tenant-id>"; $clientId = "<client-id>"; $clientSecret = "<secret>"
$mailbox = "noreply@mckimcreed.com"
$token = (Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" `
-Body @{ grant_type="client_credentials"; client_id=$clientId
client_secret=$clientSecret; scope="https://graph.microsoft.com/.default" }).access_token
$payload = @{
message = @{
from = @{ emailAddress = @{ name = "New Employee(s)"; address = $mailbox } }
subject = "Test notification"
body = @{ contentType = "HTML"; content = "<p>Hello from noreply</p>" }
toRecipients = @(@{ emailAddress = @{ address = "you@mckimcreed.com" } })
}
saveToSentItems = $false
} | ConvertTo-Json -Depth 6
Invoke-RestMethod -Method POST `
-Uri "https://graph.microsoft.com/v1.0/users/$mailbox/sendMail" `
-Headers @{ Authorization = "Bearer $token" } `
-ContentType "application/json" -Body $payload
A 202 Accepted (no body) means it sent.
Comments
0 comments
Please sign in to leave a comment.