Building Outlook addin with SPFx - save mail to OneDrive with Azure Function, MSAL.NET and MS Graph .NET

In this post, we're going to build a prototype of an outlook add-in, which saves the current email to your OneDrive. The interesting part is, that our add-in will be SPFx based, and our code, which saves emails, is hosted on Azure Functions and uses MSAL.NET for authentication and MS Graph .NET library to interact with MS Graph API.

Important! On the moment of writing (March 2021), SPFx Office add-ins support is still in preview. You can only build an add-in for outlook web. Thus I don't provide any production deployment instruction, because there is a chance, that it will change in the future.

The source code for this post is available on GitHub here.

How to setup it for development

Create a new App Registration in the Azure portal

Go to your Azure portal and add a new app registration. Give it a name (SPFx-Graph-Core API in my case):

Under Certificates & secrets generate a new Client Secret and save value somewhere (we'll need it later). Under the Overview, tab save Application (Client) Id and Directory (Tenant) Id values. 

Add below permissions:

Click on "Grant admin consent for ..." to trust all added permissions. 

We've just configured an app, which we will use for authentication and OAuth token generation in our azure function.

Create SPFx solution

Scaffold a new React-based SPFx web part. 

Important. Use --plusbeta, if SPFx Office addins support is still in preview (like in my case):

yo @microsoft/sharepoint --plusbeta

Select options like on the image below (important - Select y to ensure that your web part is automatically deployed tenant-wide when it's added to the tenant App Catalog): 

The code inside webpart is fairly simple - it sends a request to our Azure Functions endpoint passing tenantId and mailId parameters:

export const OutlookHostedAddin: FC<IOutlookHostedAddinProps> = (props) => {
  const [saving, setSaving] = useState(false);
  const [saved, setSaved] = useState(false);

  const saveToOneDrive = async () => {
    setSaving(true);
    setSaved(false);

    const client = await props.context.aadHttpClientFactory.getClient('<client id>');
    const tenantId = props.context.pageContext.aadInfo.tenantId;
    const mailId = Office.context.mailbox.convertToRestId(props.context.sdks.office.context.mailbox.item.itemId, Office.MailboxEnums.RestVersion.v1_0);

    await client.post(`http://localhost:7071/api/SaveMail/${tenantId}/${mailId}`, AadHttpClient.configurations.v1, {});

    setSaving(false);
    setSaved(true);
  };

  return (
    <div className={styles.outlookHostedAddin}>
      <PrimaryButton text={saving ? "Saving..." : "Save to OneDrive"} onClick={saveToOneDrive} disabled={saving} />
      {saved &&
        <div>Your email was saved!</div>}
    </div>
  );
};

The code uses AadHttpClient to send an authenticated request to our Azure Function. SPFx under the hood silently generates OAuth bearer access token for us and sends it in the Authorization HTTP header. 

Update SPFx package-solution.json

Now we need to add our API to the list of Web API permissions inside config/package-solution.json. Open it and add the below section: 

"webApiPermissionRequests": [
      {
        "resource": "SPFx-Graph-API",
        "scope": "user_impersonation"
      },
      {
        "resource": "Windows Azure Active Directory",
        "scope": "User.Read"
      }
    ]

Where SPFx-Graph-API is your App Registration name. What does scope user_impersonation mean? It means, that we request OAuth access token just for authentication against our service. 

Deploy SPFx solution and approve permission request

Run

gulp bundle --ship
gulp package-solution --ship

Upload the resulting package to your SharePoint App Catalog. 

Now navigate to your SharePoint admin center, API access page and approve all requested permissions.

Create Azure Function

Create a new function using the Visual Studio template. Add NuGet packages Microsoft.Graph and Microsoft.Identity.Client (MSAL.NET).

Update your local.settings.json file with additional values for Client id and Secret. Also, add CORS support: 

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "ClientSecret": "<your client secret>",
    "ClientId": "<your client id>"
  },
  "Host": {
    "CORS": "*"
  }
}

For our azure function, we need dependency injection configuration, to inject our configuration values (Client id and secret). Thus add Startup class to your function: 

public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            var config = builder.GetContext().Configuration;
            var appInfo = new AppInfo();
            config.Bind(appInfo);

            builder.Services.AddSingleton(appInfo);
        }
    }

Where AppIinfo is just a container class:

public class AppInfo
    {
        public string ClientId { get; set; }
        public string ClientSecret { get; set; }
    }

AddSingleton registers the instance of AppInfo so that it becomes available in our function's constructor via DI.

Now let's explore the content of Azure Function. Constructor first:

public DefaultFunctions(AppInfo appInfo)
        {
            _appInfo = appInfo;
        }

In the constructor, via DI we receive our app's info like ClientId and ClientSecret

Function body:

[FunctionName("SaveMail")]
public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "SaveMail/{tenantId}/{mailId}")] HttpRequestMessage request,
 string tenantId, string mailId,
	ILogger log)
{
	var accessToken = request.Headers.Authorization.Parameter;
	var graphClient = CreateGraphClient(tenantId, accessToken);
	var mail = await graphClient.Me.Messages[mailId].Request().GetAsync();
	var mailStream = await graphClient.Me.Messages[mailId].Content.Request().GetAsync();

	// upload to root OneDrive folder
	await graphClient.Me.Drive.Root.ItemWithPath(mail.Subject + ".eml").Content.Request().PutAsync<DriveItem>(mailStream);

	return new OkResult();
}

Here we use MS Graph .NET library (GraphServiceClient) to access MS Graph API. We create a client (how - read further), then we extract mailId from the query, get mail, and mail content. In the last step, we save the content to the root folder at our OneDrive. 

The creation of GraphServiceClient is a key point here:

public GraphServiceClient CreateGraphClient(string tenantId, string accessToken)
{
	var confidentialClientApplication = ConfidentialClientApplicationBuilder
				.Create(_appInfo.ClientId)
				.WithClientSecret(_appInfo.ClientSecret)
				.WithTenantId(tenantId)
				.Build();
	var userAssertion = new UserAssertion(accessToken);
	var authProvider = new DelegateAuthenticationProvider(
			async (requestMessage) =>
			{
				var tokenResult = await confidentialClientApplication.AcquireTokenOnBehalfOf(new string[] { "https://graph.microsoft.com/.default" }, userAssertion).ExecuteAsync().ConfigureAwait(false);

				requestMessage.Headers.Authorization =
					new AuthenticationHeaderValue("Bearer", tokenResult.AccessToken);
			});

	return new GraphServiceClient(authProvider);
}

To access MS Graph, we need to be authenticated. We cannot use the access token, received in Azure Function because it's valid only for our Function endpoint. However, we can use on-behalf-of flow to get access token for MS Graph.

How it works? Our azure function uses Azure AD authentication, so we have OAuth token for our function available in the HTTP header. Our app registration also lists additional permissions to other services (or better to say APIs) - MS Graph. On-behalf-of flow allows us to exchange our API's access token for another token API, listed in the app registration. 

For that purpose we use MSAL.NET library. ConfidentialClientApplication is a container, which we use to access different authentication options.  AcquireTokenOnBehalfOf does exactly that - authenticate us against MS Graph using an on-behalf exchange mechanism. 

NOTE: Upon real deployment to production, you should configure Azure AD authentication for your Azure Function app.

Run it

Hit F5 to run Azure Function.

Copy function url and update your webpart. 

Run gulp serve in SPFx solution. 

Install solution to Outlook Web Access

At this step, you should sideload outlook addin into your web client. The manifest is located at officeAddin\<guid>_outlookManifest.xml . For this step, you can follow Microsoft documentation here

Test it!

Now you can open your addin in the outlook for the web, then click Save Mail button and your mail should be saved in OneDrive. 

To summarize, what we achieved here:

  • Azure AD protected Azure Function, which we call from our SPFx web part
  • In Azure Function we use on-behalf-flow to exchange our app access token for MS Graph token
  • We do it with MSAL.NET library
  • We access MS Graph API with MS Graph.NET library

Hope this tutorial was helpful. Please ask your questions in the comments.

Title image credits - Marketing vector created by stories - www.freepik.com