Introduction
Kirk Evans recently wrote a blog post titled Call O365 Exchange Online API from a SharePoint App. Kirk’s post reminded me of some work that I had done with a non-profit around managing Exchange Online from a SharePoint app. This non-profit is setup such that there is an international board of directors, state level boards, and finally individual chapters in various locations around the globe that actually do the work of the organization. In each chapter, an IT administrator exists and is tasked with creating user accounts, resetting passwords, manage group membership, etc. so we needed a way that each of those IT admins could accomplish those tasks without actually being O365 administrators.. This organization is also leveraging the free Azure websites that are provided with Office 365, so the solution needed to run there as well. After what seemed like 100 iterations, we finally settled on a combination of the Azure Active Directory Graph Client Library (Getting started with the Azure Active Directory Graph Client Library) and Exchange Online PowerShell. This post will discuss the ins and outs of interacting with Exchange Online PowerShell from C#, and ultimately, from a web app that could easily be rendered in SharePoint Online via a web part.
What don’t you know?
It turns out that if you’ve ever worked with PowerShell in C# before, this is basically the same – except using Exchange Online cmdlets. Exchange Online has a unique way of providing access to its functionality. Rather than just using Import-Module to import some Exchange Online module, Exchange Online actually requires that we use Remote PowerShell to access its own servers. That’s cool – less files to worry about having installed into the right places. However, it does add some extra code. If we just give it a go, we might try some code like the following:
PowerShell session = PowerShell.Create();
string script = @"
param(
[string]$userName,
[System.Security.SecureString]$password
)
$creds = New-Object System.Management.Automation.PSCredential ($userName, $password)
$exchangeSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri ""https://outlook.office365.com/powershell-liveid/"" -Authentication Basic -AllowRedirection -Credential $creds
Import-PSSession $exchangeSession -AllowClobber
$alexD = Get-Mailbox | where { $_.DisplayName -eq ""Alex Darrow"" }
Set-Mailbox -Identity $alexD.Identity -DisplayName ""Alex Darrow (Marketing)""
Remove-PSSession $exchangeSession.ID
return $mailboxes
";
Collection<PSObject> results = session.AddScript(script).AddParameter("userName", "admin@jonhuss2.onmicrosoft.com")
.AddParameter("password", GetPassword()).Invoke();
This code looks up our user, Alex Darrow, and then changes his DisplayName property. This works fine in Visual Studio, albeit a little slow, but we’re thinking a little out of the box here, so we carry on and write an entire application around this idea. Life is good. Then we deploy to an Azure web app and are presented with an error that says Files cannot be loaded because the running of scripts is disabled on this system. Please provide a valid certificate with which to sign the files. and might look something like the image below, if we were to just dump exceptions to the browser (never do this in a production environment!).
Our application would probably work fine if we were to deploy it to a local server. It may also work ok if we were to use one of the Azure cloud roles, and would almost certainly work in an Azure IaaS installation (although I haven’t verified this). However, I want to deploy this to an Azure web app so that I can take advantage of the free Azure web apps that are provided with Office 365, as well as keep everything in a neat, tidy, cloud package and not have to worry about on-prem installations.
Why doesn’t this work?
As we witnessed, the real challenge arises when attempting to deploy to Azure. There are a couple of major challenges:
- Azure web apps aren’t particularly thrilled about us running scripts in their systems. If you take your favorite script, write code to run it in your application, deploy your application to Azure, and then expect to see your fancy results, you’ll be sorely disappointed. It doesn’t really matter what certificate you use to sign your script, it won’t work because these are Microsoft hosted machines and Microsoft doesn’t trust you to run PowerShell on those boxes. Microsoft probably has some internal certificates that are used by the various engineering teams for maintenance, upgrades, etc., but they certainly aren’t going to share those certs with you. So, you’re pretty much out of luck here, it seems.
- Opening a connection to Exchange Online via New-PSSession takes a LONG time. This is because when you open that connection to Exchange Online, it has to download all of the various cmdlets, documentation, etc. to your local machine. This probably isn’t a big deal if you’re doing this as an Exchange Administrator and you’re just starting a script and waiting for it to run as a monthly maintenance task or something. However, if this is something that you’re trying to embed in an interactive website, performance matters.
So what’s the solution?
The first step is that we need to open a remote connection to the Exchange Online server instead of running the commands locally. This is roughly equivalent to using Enter-PSSession to create a PowerShell session on a remote machine. However, since Azure web apps aren’t going to just let us fire up a PowerShell session and Enter-PSSession, we’re going to open our session directly on the remote machine. We can do that using the following code:
WSManConnectionInfo ci = new WSManConnectionInfo(new Uri("https://outlook.office365.com/powershell-liveid/"),
"http://schemas.microsoft.com/powershell/Microsoft.Exchange",
new PSCredential("admin@jonhuss2.onmicrosoft.com", GetPassword()));
ci.AuthenticationMechanism = AuthenticationMechanism.Basic;
using (Runspace runspace = RunspaceFactory.CreateRunspace(ci))
{
using (PowerShell session = PowerShell.Create())
{
runspace.Open();
session.Runspace = runspace;
// Script content goes here
session.AddCommand("Exit-PSSession").Invoke();
runspace.Close();
}
}
You might be thinking “Great! Just grab that PowerShell script from above and stuff it in there using session.AddScript(…) and we’re golden!”. Not so fast. If we give that a go, we actually end up with an exception that says The syntax is not supported by this runspace. This might be because it is in no-language mode. This is because Exchange Online has been configured to not allow you to run any of the PowerShell language elements (ifs, loops, arrays, etc.) and only run single cmdlets, using session.AddCommand(…). We could still accomplish some tasks with that restriction, but the depth of those tasks would definitely be limited. To work around this restriction, we need to convert our scripts from PowerShell to .NET code (in this case, C#) so that we can run them . The script from above that changes Alex Darrow’s DisplayName might be converted as follows:
Collection<PSObject> results = session.AddCommand("Get-Mailbox").Invoke();
session.Commands.Clear();
PSObject alexD = results.Where(r => r.Properties["DisplayName"].Value.ToString() == "Alex Darrow").First();
session.AddCommand("Set-Mailbox").AddParameter("Identity", alexD.Properties["Identity"].Value)
.AddParameter("DisplayName", "Alex Darrow (Engineering)")
.Invoke();
session.Commands.Clear();
If we take that code and inject it into our remote session code from above, it might look like this:
WSManConnectionInfo ci = new WSManConnectionInfo(new Uri("https://outlook.office365.com/powershell-liveid/"),
"http://schemas.microsoft.com/powershell/Microsoft.Exchange",
new PSCredential("admin@jonhuss2.onmicrosoft.com", GetPassword()));
ci.AuthenticationMechanism = AuthenticationMechanism.Basic;
using (Runspace runspace = RunspaceFactory.CreateRunspace(ci))
{
using (PowerShell session = PowerShell.Create())
{
runspace.Open();
session.Runspace = runspace;
Collection<PSObject> results = session.AddCommand("Get-Mailbox").Invoke();
session.Commands.Clear();
PSObject alexD = results.Where(r => r.Properties["DisplayName"].Value.ToString() == "Alex Darrow").First();
session.AddCommand("Set-Mailbox").AddParameter("Identity", alexD.Properties["Identity"].Value)
.AddParameter("DisplayName", "Alex Darrow (Engineering)")
.Invoke();
session.Commands.Clear();
session.AddCommand("Exit-PSSession").Invoke();
runspace.Close();
}
}
Finally! Code that will execute PowerShell against Exchange Online from an Azure web app. Pretty simple, right?
What else?
Now that we have code that will run in an Azure web app, we should probably pretty it up a bit. Certainly you don’t want to have the same code over and over to open and close the PowerShell connections and such. However, with the need for the using(…) blocks to ensure bits are properly disposed, it makes things a bit complicated. One way to solve this is to use generic delegates. Generic delegates incorporate two C# concepts: Generics and Delegates. Generics allow you to tell C# what return type you’d like to receive (Dictionary<string, string> is used frequently below) represented by the variable ‘T’ (for Type!) below. Delegates allow you to pass functions as parameters so that another function can call that passed function. The sample code below passes delegates that contain the C# versions of our scripts. It has a handful of Exchange Online functions included to demonstrate how multiple functions can share the same outer management bits.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Security;
using System.Web;
namespace ExchangeOnlineAdminTest
{
public class ExchangeOnline
{
private T RunScript<T>(ExchangeScript<T> myDelegate, Dictionary<string, object> parms = null)
{
WSManConnectionInfo ci = new WSManConnectionInfo(new Uri("https://outlook.office365.com/powershell-liveid/"),
"http://schemas.microsoft.com/powershell/Microsoft.Exchange",
new PSCredential("admin@jonhuss2.onmicrosoft.com", GetPassword()));
ci.AuthenticationMechanism = AuthenticationMechanism.Basic;
T returnValue = default(T);
using (Runspace runspace = RunspaceFactory.CreateRunspace(ci))
{
using (PowerShell session = PowerShell.Create())
{
runspace.Open();
session.Runspace = runspace;
returnValue = myDelegate.Invoke(session, parms);
session.AddCommand("Exit-PSSession").Invoke();
runspace.Close();
}
}
return returnValue;
}
public Dictionary<string, string> GetLastLogons()
{
ExchangeScript<Dictionary<string, string>> getLastLogons = (session, parms) =>
{
/* PowerShell equivalent:
* Get-Mailbox |
* where { $_.RecipientTypeDetails -eq "UserMailbox" } |
* Get-MailboxStatistics |
* ft DisplayName, LastLogonTime
*/
Dictionary<string, string> lastLogons = new Dictionary<string, string>();
Collection<PSObject> mailboxes = session.AddCommand("Get-Mailbox").Invoke();
session.Commands.Clear();
foreach (PSObject mailbox in mailboxes)
{
if (mailbox.Properties["RecipientTypeDetails"].Value.ToString() == "UserMailbox")
{
Collection<PSObject> mailboxStats = session.AddCommand("Get-MailboxStatistics").AddParameter("Identity", mailbox.Properties["UserPrincipalName"].Value).Invoke();
session.Commands.Clear();
if (mailboxStats.Any())
{
if (mailboxStats[0].Properties["LastLogonTime"].Value != null)
lastLogons.Add(mailboxStats[0].Properties["DisplayName"].Value.ToString(), mailboxStats[0].Properties["LastLogonTime"].Value.ToString());
else
lastLogons.Add(mailboxStats[0].Properties["DisplayName"].Value.ToString(), "Never logged on");
}
}
}
return lastLogons;
};
return RunScript<Dictionary<string, string>>(getLastLogons);
}
public Dictionary<string, string> GetMailboxes()
{
ExchangeScript<Dictionary<string, string>> getMailboxes = (session, parms) =>
{
/*
* PowerShell equivalent:
* Get-Mailbox | ft DisplayName, PrimarySmtpAddress
*/
Collection<PSObject> mailboxes = session.AddCommand("Get-Mailbox").Invoke();
Dictionary<string, string> mailboxList = mailboxes.ToDictionary(k => k.Properties["UserPrincipalName"].Value.ToString(),
v => v.Properties["DisplayName"].Value.ToString());
return mailboxList;
};
return RunScript<Dictionary<string, string>>(getMailboxes);
}
public Dictionary<string, string> GetGroups()
{
ExchangeScript<Dictionary<string, string>> getGroups = (session, parms) =>
{
/*
* PowerShell equivalent:
* Get-DistributionGroup | ft PrimarySmtpAddress, DisplayName
*/
Collection<PSObject> distributionGroups = session.AddCommand("Get-DistributionGroup").Invoke();
Dictionary<string, string> groups = distributionGroups.ToDictionary(k => k.Properties["PrimarySmtpAddress"].Value.ToString(),
v => v.Properties["DisplayName"].Value.ToString());
return groups;
};
return RunScript<Dictionary<string, string>>(getGroups);
}
public Dictionary<string, string> GetGroupDetails(string groupEmailAddress)
{
Dictionary<string, object> parameters = new Dictionary<string, object>();
parameters.Add("groupEmailAddress", groupEmailAddress);
ExchangeScript<Dictionary<string, string>> getGroupDetails = (session, parms) =>
{
/*
* PowerShell equivalent:
* Get-DistributionGroup -Identity groupEmailAddress
*/
PSObject distributionGroup = session.AddCommand("Get-DistributionGroup")
.AddParameter("Identity", parms["groupEmailAddress"])
.Invoke()
.First();
Dictionary<string, string> groupProperties = distributionGroup.Properties.OrderBy(p => p.Name)
.ToDictionary(k => k.Name, v => v.Value == null ? String.Empty : v.Value.ToString());
return groupProperties;
};
return RunScript<Dictionary<string, string>>(getGroupDetails, parameters);
}
public Dictionary<string, string> GetMailboxDetails(string userPrincipalName)
{
Dictionary<string, object> parameters = new Dictionary<string, object>();
parameters.Add("userPrincipalName", userPrincipalName);
ExchangeScript<Dictionary<string, string>> getMailboxDetails = (session, parms) =>
{
/*
* PowerShell equivalent:
* GetMailbox -Identity userPrincipalName
*/
PSObject mailbox = session.AddCommand("Get-Mailbox")
.AddParameter("Identity", parms["userPrincipalName"])
.Invoke()
.First();
Dictionary<string, string> mailboxProperties = mailbox.Properties.OrderBy(p => p.Name)
.ToDictionary(k => k.Name, v => v.Value == null ? String.Empty : v.Value.ToString());
return mailboxProperties;
};
return RunScript<Dictionary<string, string>>(getMailboxDetails, parameters);
}
public Dictionary<string, string> GetMailboxStatistics(string userPrincipalName)
{
Dictionary<string, object> parameters = new Dictionary<string, object>();
parameters.Add("userPrincipalName", userPrincipalName);
ExchangeScript<Dictionary<string, string>> getMailboxDetails = (session, parms) =>
{
/*
* PowerShell equivalent:
* GetMailboxStatistics -Identity userPrincipalName
*/
PSObject mailbox = session.AddCommand("Get-MailboxStatistics")
.AddParameter("Identity", parms["userPrincipalName"])
.Invoke()
.First();
Dictionary<string, string> mailboxStatistics = mailbox.Properties.OrderBy(p => p.Name)
.ToDictionary(k => k.Name, v => v.Value == null ? String.Empty : v.Value.ToString());
return mailboxStatistics;
};
return RunScript<Dictionary<string, string>>(getMailboxDetails, parameters);
}
private SecureString GetPassword()
{
SecureString password = new SecureString();
foreach(char letter in "**********")
{
password.AppendChar(letter);
}
return password;
}
private delegate T ExchangeScript<T>(PowerShell session, Dictionary<string, object> parameters);
}
}
Web Implementation
At this point, the web implementation is almost trivial. It’s just a matter of calling into our Exchange PowerShell functions and then doing something with the results. For example, if we start with a basic demo that has a couple of options, it might look like this:
From here, we could click on “Display Mailboxes”. The Display Mailboxes controller will route into the GetMailboxes function of the code above and the results are passed back to the interface. The rendered results look something like this (notice Alex Darrow’s updated DisplayName, from the very first block of code that we talked about – see, it really does work!):
If we click the ‘Details’ for Alex Darrow (Engineering), for example, it will route through the GetMailboxDetails function in the code above and ultimately render the details of the mailbox, like so:
We could also go back to the mailbox list and select ‘Statistics’ for Alex Darrow (Engineering) and it would route through the GetMailboxStatistics function and show us the statistics of Alex Darrow’s mailbox:
Thoughts and Gotchas
We’ve talked about some pretty basic Exchange Online calls here, but what else could we do with this? Could we manage group membership or provision new mailboxes? Yep, I’m absolutely certain that we could. At this point, the sky is sort of the limit. Whatever you can do via Exchange Online and PowerShell could be done using these techniques. What about coupling this with Azure AD or SharePoint Online APIs? At the end of the day, those are both just REST APIs, so it would be no problem at all to provide interfaces to all of those systems to perform some pretty in-depth O365 management. In the case of our non-profit, we did all of these things and then made it more interesting by embedding it all into a SharePoint Online web part that each of the chapter IT admins could use to do their tasks. Fun, right? It looks like this, with more functions being added all the time:
Gotcha: One gotcha that I encountered a number of times is regarding the number of simultaneous connections that Exchange Online will allow for a single user. As of 4/9/2015, that limit is 3. Normally this wouldn’t be all that big of a deal, but I ran into a couple of issues with it.
- Throttling. If my code were to throw an exception for some reason, and that exception wasn’t handled, then, naturally, my application crashed. As a result of that crash, I lost track of that open PowerShell runspace and didn’t Exit-PSSession correctly. If this happened a few times (as it does during debugging), Exchange Online would reject any further connections with a GlobalThrottlingPolicy error and a message that said Fail to create a runspace because you have exceeded the maximum number of connections allowed : 3 for the policy party : MaxConcurrency. Please close existing runspace and try again. During development, just restarting Visual Studio solved the problem (maybe there are better ways?). However, in a production environment, this could be a more serious challenge.
- High volume. This process is essentially single-threaded across all instances. If the application were running in a situation where many, many users were hitting it all at once (like in a website), not only could the throttling problem occur, but it may just completely bog down the system. There are probably ways that a person could create a singleton PowerShell instance and leave the runspace open for a while and send multiple commands to it. Some timeouts are built in so this becomes a little tricky to do reliably. It might also be solved by having multiple accounts available to the system such that when a new request came in from a user, the remote PowerShell was opened using whichever account was not being used at the time. Having a finite set of accounts becomes a scaling challenge in and of itself.
I’d love to hear if you’ve attempted something like this already and if so, how you solved it. Did you use the same method? Did you use a better method? Did you encounter any other challenges of your own? Tell me your story!
Yeah, you are all right.
I’m developing a tool for PowerShell and my code to throw an exception, exactly in runspace.Open() when I restarted the application.
It occurs because in previous tests I had 3 exceptions because I have MaxConcurrency over 3.
So, I think the solution is prevent all exeption and Close() the runspace, and we can change the MaximumConnectionRedirectionCount value.
I will do it now, and show you how I solve this issue.
Cheers,
Flavio Costa
Brilliant post. Was looking all over for something like this and did the trick. Thank you very much Jonathan Huss.
Mindi Gill
Mindi Gill nails it. Brilliant! Thank you very much.
I myself work on a solution to read data from exchange online in batches. Is there any way to store intermediate results into variables?
Like:
$mb = Get-Mailbox -Resultsize 100
$mb | Get-MailboxPermission
$mb | Get-MailboxStatistics
instead of calling the Get-Mailbox cmdlet multiple times.
Another possible way would be to call for example Get-MailboxPermission for each mailbox, but this will also lead to very many calls.