Use SPFx for Task Modules in Teams Messaging Extensions and access Microsoft Graph

Use SPFx for Task Modules in Teams Messaging Extensions and access Microsoft Graph

Since SPFx 1.11 you can also use SharePoint Framework for task modules in Teams messaging extensions. This further simplifies the authentication mechanism to access Microsoft Graph or other 3rd party APIs. In my last post I showed how to implement an action based messaging extension. Here is the alternative using SPFx components.

My use case scenario is:
Assume you have some documents out there that need a review from time to time and those that are “due” you wanna share inside Microsoft Teams. So a user can select inside his channel from all retrieved due documents to post them as a card into the current channel. Furthermore any other user in that channel (assuming access rights) can mark them as ‘reviewed’ for another period.

Messaging Extension Task module to select a document
Messaging Extension Task module to select a document

Contents

Setup

At first there is the need for two solutions. One SPFx webpart solution and a small Teams bot solution. The webpart can be setup the following:

Document select task module in yo @microsoft/sharepoint

There is a need for SharePoint Framework version 1.11 but no need to use (–plusbeta) here. Additionally the yeoman generator for Teams creates a small bot solution, nothing special cause most of the logic takes place in the SPFx part:

yo teams for a small bot solution

To establish the initial Messaging extension the teams manifest.json (THE ONE in the SPFx solution!) needs to be configured manually. In my github repo there is also a sample (v1.6) with placeholders that show the configurable points.

"bots": [
    {
      "botId": "{{Bot-AppID}}",
      "needsChannelSelector": false,
      "isNotificationOnly": false,
      "scopes": [
        "team"
      ]
    }
  ],
  "composeExtensions": [
    {
      "botId": "{{Bot-AppID}}",
      "canUpdateConfiguration": true,
      "commands": [
        {
          "id": "docReview",
          "type": "action",
          "title": "Doc Review Action Extension",
          "description": "{Extension description}",
          "initialRun": false,
          "fetchTask": false,
          "context": [
            "commandBox",
            "compose"
          ],
          "taskInfo": {
            "title": "Documents to review",
            "width": "1100",
            "height": "665",
            "url": "https://{teamSiteDomain}/_layouts/15/TeamsLogon.aspx?SPFX=true&dest=/_layouts/15/teamstaskhostedapp.aspx%3Fteams%26personal%26componentId={componentID}%26forceLocale={locale}"
          }
        }
      ]
    }
  ],
  "validDomains": [
    "*.login.microsoftonline.com",
    "*.sharepoint.com",
    "spoprod-a.akamaihd.net",
    "resourceseng.blob.core.windows.net"
  ],

Three important parts here:

  1. A bot channel needs to be created. Part I of my series shows how to create it, but no need here for a CustomGraphConnection.
  2. The composeExtension once again references the bot but also defines the task module in the “taskInfo” part.
    1. The {teamSiteDomain} will be automatically replaced at runtime here, no need to overwrite.
    2. The {componentID} is the most essential part: It points to the ID of the SPFx webpart so can be found in the webpart manifest (not to mixup with the solution id inside the package-solution.json!)
    3. The {locale} again will be automatically replaced at runtime here, no need to overwrite.
  3. The “validDomains” also need to include some Url from CDNs here to load SPFx components e.g.

Once the manifest is done it can be zipped in a file together with both created icons in the \teams folder. Run gulp bundle and gulp package-solution and install the .sppkg file in the tenant’s app catalog. (Best is to install the solution tenant-wide cause otherwise the task module might not work, as it needs to be present in any Team using it but also in SP’s root site (see Url in taskInfo above)).
Then install the zipped file as a Teams app in your tenant and add it to your Team. Now the messaging extension would already be available in Teams and render the classic ‘Hello World’ webpart after click on:

Invoke Teams Messaging Extension

But no need to show the simple ‘Hello World’ stuff. Instead lets have a look on the detailed implementation.

The “Select” task module

In the root class of the webpart some properties need to be handed over to the root react component:

export default class DocReviewSelectWebPart extends BaseClientSideWebPart<IDocReviewSelectWebPartProps> {
  private isTeamsMessagingExtension;

  protected onInit(): Promise<void> {
    initializeIcons(); // Not needed inside SharePoint but inside Teams!
    this.isTeamsMessagingExtension = (this.context as any)._host && 
                                      (this.context as any)._host._teamsManager &&
                                      (this.context as any)._host._teamsManager._appContext &&
                                      (this.context as any)._host._teamsManager._appContext.applicationName &&
                                      (this.context as any)._host._teamsManager._appContext.applicationName === 'TeamsTaskModuleApplication';    
    return Promise.resolve();                                      
  }

  public render(): void {
    const element: React.ReactElement<IDocReviewSelectProps> = React.createElement(
      DocReviewSelect,
      {
        serviceScope: this.context.serviceScope,
        siteUrl: this.context.pageContext.site.absoluteUrl,
        isTeamsMessagingExtension: this.isTeamsMessagingExtension,
        teamsContext: this.context.sdks.microsoftTeams
      }
    );

    ReactDom.render(element, this.domElement);
  }
...
}

The serviceScope, a siteUrl and the teamsContext for use with the sdk are needed later. But it also can be already detected if the code is running inside the context of a Messaging Extension. This takes place in onInit here and is handed “down” as boolean result only.

The react part of the webart now renders as a FunctionComponent with hooks.

const DocReviewSelect: React.FunctionComponent<IDocReviewSelectProps> = (props) => {
  const [documents, setDocuments] = useState([] as IDocument[]);
  const columns = [
    { key: 'columnPre', name: '', fieldName: 'urgent', minWidth: 12, maxWidth: 12, isResizable: false },
    { key: 'column1', name: 'Name', fieldName: 'name', minWidth: 60, maxWidth: 150, isResizable: true },
    { key: 'column2', name: 'Description', fieldName: 'description', minWidth: 100, maxWidth: 150, isResizable: true },
    { key: 'column3', name: 'Created by', fieldName: 'author', minWidth: 100, maxWidth: 200, isResizable: true },
    { key: 'column4', name: 'Next Review', fieldName: 'nextReview', minWidth: 50, maxWidth: 100, isResizable: true },
    { key: 'column5', name: 'Url', fieldName: 'url', minWidth: 100, maxWidth: 200, isResizable: true }
  ];

  const getDocsForReview = async () => {
    const graphService: GraphService = new GraphService();
    graphService.initialize(props.serviceScope, props.siteUrl)
      .then(() => {
        return graphService.getDocumentsForReview()
        .then((docs) => {
          setDocuments(docs);
        });     
      });
  };

  useEffect(() => {
    if (documents && documents.length < 1) {
      getDocsForReview();        
    }
  });
... omitted for brevity
  const docSelected = (item: any): void => {    
    if (props.isTeamsMessagingExtension) {
      props.teamsContext.teamsJs.tasks.submitTask(item);
    }
  };

  return (
    <div className={ styles.docReviewSelect }>
      <div className={ styles.container }>
        <div className={ styles.row }>
        <DetailsList compact={true}
            items={documents}
            columns={columns}
            onRenderItemColumn={renderItemColumn}
            onRenderRow={renderRow}
            setKey="set"
            layoutMode={DetailsListLayoutMode.justified}
            onItemInvoked={docSelected} />
        </div>
      </div>
    </div>
  );
  
};

export default DocReviewSelect;

The full file is available in my github repo but the essential points are

  • With a simple Effect hook the docs are loaded from a service and set to the State (hook)
  • The docs are rendered in a FluentUI Details list
  • Once an item is selected (double click here) the corresponding function ‘docSelected’ submits a TaskActivity and this is where the connection to the bot and the corresponding (backend) solution takes place …

The bot SubmitTask

In the bot there is only the need to edit two files:
In the .env file the bot app ID and it’s corresponding secret as well as the hostname (ngrok for temp access here)

# The domain name of where you host your application
HOSTNAME=0110ece162f3.ngrok.io
# App Id and App Password for the Bot Framework bot
MICROSOFT_APP_ID=00000000-0000-0000-0000-000000000000
MICROSOFT_APP_PASSWORD=

And the whole code takes place in the app\<YourBotName>\<YourBotName>.ts
Once the above submitTask(item) function was called, it arrives in the following backend function:

protected async handleTeamsMessagingExtensionSubmitAction(context: TurnContext, action: MessagingExtensionAction): Promise<any> {
    const docCard = CardFactory.adaptiveCard(
      {
        type: "AdaptiveCard",
        body: [
          {
            type: "ColumnSet",
            columns: [
                {
                  type: "Column",
                  width: 25,
                  items: [
                    {
                      type: "Image",
                      url: `https://${process.env.HOSTNAME}/assets/icon.png`,
                      style: "Person"
                    }
                  ]
                },
                {
                  type: "Column",
                  width: 75,
                  items: [
                    {
                      type: "TextBlock",
                      text: action.data.name,
                      size: "Large",
                      weight: "Bolder"
                    },
                    {
                      type: "TextBlock",
                      text: action.data.description,
                      size: "Medium"
                    },
                    {
                      type: "TextBlock",
                      text: `Author: ${action.data.author}`
                    },
                    {
                      type: "TextBlock",
                      text: `Modified: ${action.data.modified}`
                    }
                  ]
                }
            ]
          }
        ],
        actions: [
          {
              type: "Action.OpenUrl",
              title: "View",
              url: action.data.url
          },          
          {
            type: "Action.Submit",
            title: "Reviewed",
            data: {
              item: action.data,
              msteams: {
                type: "task/fetch"
              }  
            } 
                 
          }
        ],
        $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
        version: "1.0"
      });
    const response: MessagingExtensionActionResponse = {
      composeExtension: {
        type: 'result',
        attachmentLayout: 'list',
        attachments:  [ docCard ]
      }
    }
    return Promise.resolve(response);
  }

Using parts of the item, that is the selected document element an AdaptiveCard is created. The most important point is the “Action.Submit” for executing the “Reviewed” activity which also retrieves the item. Beyond that the “msteams” part inside data classifies a “task/fact” action. This will later trigger another function inside the bot. But first the result of our card is here

Document for review in an Adaptive Card

So once the user (or another one) clicks on the “Reviewed” button a fetch task is executed and the bot will route it to the following function:

protected handleTeamsTaskModuleFetch(context: TurnContext, value: any): Promise<any> {
    const componentID = '75f1c63b-e3d1-46b2-957f-3d19a622c463';
    const itemID = value.data.item.key;
    return Promise.resolve({
      task: {
        type: "continue",
        value: {
          title: "Mark document as reviewed",
          height: 500,
          width: "medium",
          url: `https://{teamSiteDomain}/_layouts/15/TeamsLogon.aspx?SPFX=true&dest=/_layouts/15/teamstaskhostedapp.aspx%3Fteams%26personal%26componentId=${componentID}%26forceLocale={locale}%26itemID=${itemID}`,
          fallbackUrl: `https://{teamSiteDomain}/_layouts/15/TeamsLogon.aspx?SPFX=true&dest=/_layouts/15/teamstaskhostedapp.aspx%3Fteams%26personal%26componentId=${componentID}%26forceLocale={locale}`
        }
      }
    });
  }

This function reminds a bit on the beginning where the manifest.json was created. And indeed another task module shall be fired up here. Two parameters are essential: The ID of the corresponding item which will be attached to the url as a custom parameter and a componentID. This time it’s another one. That is because another task module shall show up and that needs to be created as a 2nd webpart but inside the existing solution:

The ‘Reviewed’ task module

The root class of the webpart mainly works the same like above. It detects if running in the context of a messaging extension e.g. But on top it needs to retrieve the itemID from the query parameters of the url:

public render(): void {
    const queryParms = new UrlQueryParameterCollection(window.location.href);
    const itemID = queryParms.getValue("itemID");
    const element: React.ReactElement<IDocReviewMarkReviewedProps> = React.createElement(
      DocReviewMarkReviewed,
      {
        itemID: itemID,
        serviceScope: this.context.serviceScope,
        siteUrl: this.context.pageContext.site.absoluteUrl,
        isTeamsMessagingExtension: this.isTeamsMessagingExtension,
        teamsContext: this.context.sdks.microsoftTeams
      }
    );

    ReactDom.render(element, this.domElement);
  }

In the root react component now an additional DatePicker is rendered which will be used to set the date for the next review. Finally a button can be clicked to update the document.

The ‘Reviewed’ task module
const DocReviewMarkReviewed:React.FunctionComponent<IDocReviewMarkReviewedProps> = (props) => {
  const [nextReview, setNextReview] = useState(new Date() as Date);

  const setReviewDate = (date: Date) => {
    setNextReview(date);
  };

  const execReview = async () => {
    if (!props.isTeamsMessagingExtension) {
      return;
    }    
    const fieldValueSet = {
      LastReviewed: new Date().toISOString(),
      NextReview: nextReview.toISOString()
    };
    const graphService: GraphService = new GraphService();
    graphService.initialize(props.serviceScope, props.siteUrl)
      .then(() => {
        graphService.setDocumentReviewed(props.itemID, fieldValueSet)
          .then((responseDoc) => {
            props.teamsContext.teamsJs.tasks.submitTask();
          });
      });    
  };

  return (
    <div className={ styles.docReviewMarkReviewed }>
      <div className={ styles.container }>
        <div className={ styles.row }>
          <div className={ styles.column }>
              <DatePicker
                className={styles.dateControl}
                firstDayOfWeek={DayOfWeek.Monday}
                label="Next Review"
                placeholder="Select a date for next review..."
                ariaLabel="Select a date"
                showWeekNumbers={true}
                onSelectDate={setReviewDate}
                value={nextReview}
              />
            </div>
            <div className={ styles.column }>
              <DefaultButton text="Reviewed"
                              onClick={execReview} />
            </div>
        </div>
      </div>
    </div>
  );
};

export default DocReviewMarkReviewed;

Once the button is clicked again a service is called to update the document. Finally a submitTask is executed to inform the button that the activity is closed. This will also end the task module and get back to the Teams client.

Configuration

At the end lets also have a short look at the services implementation. Especially about the configuration cause in part I.I of my series there was the need to define a siteID and a listID from where the documents are collected. And the “settings” of the messaging extension cannot be implemented as in part IV of my series.

As we have SPFx here and with it very simple access to SharePoint why not use tenant properties? I created a small PnP PowerShell script to configure a tenant property named ‘DocReviewConfig’ with a simple JSON string containing both needed values (siteID, listID). In our graph service this is now consumed the following way:

export default class GraphService {
	private client: MSGraphClient;
	private spService: SPService;

	public async initialize (serviceScope, siteUrl: string) {
		const graphFactory: MSGraphClientFactory = serviceScope.consume(MSGraphClientFactory.serviceKey);
		this.spService = new SPService();
    this.spService.initialize(serviceScope, siteUrl);
		return graphFactory.getClient()
			.then((client) => {
				this.client = client;
				return Promise.resolve();
			});
	}

	public async setDocumentReviewed(itemID: string, fieldValueSet) {		
    const config: IConfig = await this.spService.getConfig();
		return this.client.api(`https://graph.microsoft.com/v1.0/sites/${config.siteID}/lists/${config.listID}/items/${itemID}/fields`)
      .patch(fieldValueSet)
      .then((response) => {
        return response;
      })
      .catch((error) => {
        console.error(error);
      });
	}
}

Already inside the react components where the service was consumed, two steps could be observed: The initialization and the function call afterwards. That is because the graphFactory.getClient() is asynchronous. In the executing function (here only the setDocument… as an example) first a config object from the parallel opened spService is retrieved. With its values then the call against Microsoft Graph and the dedicated site and list can be executed. Finally a short look into the spService:

export default class SPService {
  private spClient: SPHttpClient;
  private siteUrl: string;

  public initialize (serviceScope, siteUrl: string) {
    this.spClient = serviceScope.consume(SPHttpClient.serviceKey);
    this.siteUrl = siteUrl;
  }

  public async getConfig (): Promise<IConfig> {
    const requestUrl = `${this.siteUrl}/_api/web/GetStorageEntity('DocReviewConfig')`;    
    
    return this.spClient
      .get(requestUrl, SPHttpClient.configurations.v1)
        .then((response): Promise<IConfig> => {
          return response.json()
            .then((jsonResponse) => {
              const config: IConfig = JSON.parse(jsonResponse.Value);
              return config;
            });
        });
  }
}

But that’s no rocket science anymore.

I hope this is another interesting example beyond Microsoft’s ‘classic’ Leads bot solution used to introduce SPFx usage in Teams task modules. I tried to explain a bit more of the details and slightly different capabilities here. For sure I did not cover all aspects but this is ‘brand new’ at the time of writing and I am also still learning 😉
SPFx makes the authentication (did we even use that word so far?) a lot easier. And indeed we could also have used SPHttpClient to access our documents in an even simpler way. But I wanted to keep my example ‘comparable’ to the previous approaches so I remained using Microsoft Graph. Maybe this helps for other scenarios.
Finally find the whole solution in my github repo.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 and SharePoint Online development. He loves the new SharePoint Framework as well as some backend stuff around Azure Automation or Azure Functions and also has a passion for Microsoft Graph.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
Although if partially inspired by his daily work opinions are always personal.

A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph V

A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph V

The action based variant

I recently started to deep-dive in Microsoft Teams development and I believe it will become a big thing especially around “integration”. But what’s one of the main points when it comes to integrate systems, it’s all about authentication. This is missing in the very basic starter documentation and that’s for reasons. Outside the SPFx world it’s not one of the easiest tasks.

My use case scenario is:
Assume you have some documents out there that need a review from time to time and those that are “due” you wanna share inside Microsoft Teams. So a user can select inside his channel from all retrieved due documents to post them as a card into the current channel.

In the previous parts we were simply showing the results from our query on documents needing a review in a simple and less customizable way:

Search Based Messaging Extension to review documents

But there are other options when entering a messaging extension and those are much more customizable. In an action based variant information could be retrieved by UI created by an Adaptive Card, static properties or a Task Module. To reuse the existing scenario, assume we are not satisfied with the easy rendering of above search result and want to do it in a more customized way to present the retrieved documents. A custom task modul can be the way to go.

First we need to setup another project with the yeoman teams generator which could be done with the following selections:

Yeoman Teams generator creating a Messaging Extension (action based)

As in part I of this series a bot channel needs to be created but this time without a custom connection.

The app registration is also slightly different. Against the search based scenario now the authentication takes place in the frontend. That is the same scenario than establishing an SSO inside a Teams Tab followed by an on-behalf flow request for an access token. Details can be found in following two resources:

As in the first part an app registration with a client secret and the necessary permissions needs to be created. But on top an api needs to be exposed the following way:

Expose Api for Teams SSO

The essential thing here is the Application ID Uri which is composed of the given ngrok url (or later a production based web service url, but currently NO standard *.azurewebsites.net supported!) followed by the app id.
Next is a scope “access_as_user” to be consented by admin and users with corresponding text messages. And finally two client applications added, that is the Teams app and the web based application.

Once the app registration is done, it needs to be added to the app. First in the app manifest for the SSO operation:

"webApplicationInfo": {
    "id": "{{GRAPH_APP_ID}}",
    "resource": "api://{{HOSTNAME}}/{{GRAPH_APP_ID}}"
}

And next in the .env file

GRAPH_APP_ID=82103d2b-6659-454e-923f-e921b436faee
GRAPH_APP_SECRET=.iMylSCL~X6-D20buw4P8GG.5wu.u_3xI.

The option to refer to the .env file when building the manifest is used here so no need to enter the app id twice respectively change a temp. ngrok url all the time in several positions.

Now coding can start. First the SSO needs to be implemented which will be done in the frontend part. This is located at src\app\scrips\<extensionName>\<extensionName>Action.tsx

public componentWillMount() {
    this.updateTheme(this.getQueryVariable("theme"));

    microsoftTeams.initialize(() => {
      microsoftTeams.registerOnThemeChangeHandler(this.updateTheme);
      microsoftTeams.getContext((context) => {
        this.setState({
          entityId: context.entityId
        });
        this.updateTheme(context.theme);
        microsoftTeams.authentication.getAuthToken({
          successCallback: (token: string) => {
            const decoded: { [key: string]: any; } = jwt.decode(token) as { [key: string]: any; };
            this.setState({ name: decoded!.name,
                            ssoToken: token });
            this.loadFiles();
            microsoftTeams.appInitialization.notifySuccess();
          },
          failureCallback: (message: string) => {
            this.setState({ error: message });
            microsoftTeams.appInitialization.notifyFailure({
                reason: microsoftTeams.appInitialization.FailedReason.AuthFailed,
                message
            });
          },
          resources: [`api://${process.env.HOSTNAME}/${process.env.GRAPH_APP_ID}`]
        });
      });
    });
  }

In the Teams initialize function an authToken is retrieved. If this is successful the given token (an ID token only!) can be stored in the state and the loadFiles() function can be called. To identify the configured app, the resource api is given as well.
Next the files can be retrieved:

private loadFiles = () => {
    if (this.state.ssoToken) {
      Axios.get(`https://${process.env.HOSTNAME}/api/files`, {
                      responseType: "json",
                      headers: {
                          Authorization: `Bearer ${this.state.ssoToken}`
                      }
          }).then(result => {
            let docs: IDocument[] = [];
            result.data.forEach(d => {
              docs.push({ name: d.name, id: d.id, description: d.description, author: d.author, nextReview: new Date(d.nextReview), modified: new Date(d.modified), url: d.url });
            });
            this.setState({
                documents: docs
            });
          })
          .catch((error) => {
              console.log(error);
          });
    }
  

Here the ssoToken is used for authentication and a rest call is made against the backend of the same app. Once the documents are returned they are transformed and set to the state. But what happens meanwhile in the backend?

export const graphRouter = (options: any): express.Router =>  {
  const router = express.Router();

  // Set up the Bearer Strategy
  const bearerStrategy = new BearerStrategy({
      identityMetadata: "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
      clientID: process.env.GRAPH_APP_ID as string,
      audience: `api://${process.env.HOSTNAME}/${process.env.GRAPH_APP_ID}`,
      loggingLevel: "info",
      loggingNoPII: false,
      validateIssuer: false,
      passReqToCallback: false
  } as IBearerStrategyOption,
      (token: ITokenPayload, done: VerifyCallback) => {
          done(null, { tid: token.tid, name: token.name, upn: token.upn }, token);
      }
  );
  const pass = new passport.Passport();
  router.use(pass.initialize());
  pass.use(bearerStrategy);

  const exchangeForToken = (tid: string, token: string, scopes: string[]): Promise<string> => {
    return new Promise((resolve, reject) => {
        const url = `https://login.microsoftonline.com/${tid}/oauth2/v2.0/token`;
        const params = {
            client_id: process.env.GRAPH_APP_ID,
            client_secret: process.env.GRAPH_APP_SECRET,
            grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
            assertion: token,
            requested_token_use: "on_behalf_of",
            scope: scopes.join(" ")
        };

        Axios.post(url,
            qs.stringify(params), {
            headers: {
                "Accept": "application/json",
                "Content-Type": "application/x-www-form-urlencoded"
            }
        }).then(result => {
            if (result.status !== 200) {
                reject(result);
                log(result.statusText);
            } else {
                resolve(result.data.access_token);
            }
        }).catch(err => {
            // error code 400 likely means you have not done an admin consent on the app
            log(err.response.data);
            reject(err);
        });
    });
  };
router.get(
    "/files",
    pass.authenticate("oauth-bearer", { session: false }),
    async (req: express.Request, res: express.Response, next: express.NextFunction) => {
        const user: any = req.user;
        const today = new Date().toISOString().substr(0,10);
        const siteID = process.env.SITE_ID;
        const listID = process.env.LIST_ID;
        const requestUrl: string = `https://graph.microsoft.com/v1.0/sites/${siteID}/lists/${listID}/items?$filter=fields/NextReview lt '${today}'&expand=fields`;
        
        try {
            const accessToken = await exchangeForToken(user.tid,
                req.header("Authorization")!.replace("Bearer ", "") as string,
                ["https://graph.microsoft.com/user.read"]);
            log(accessToken);
            Axios.get(requestUrl, {
                headers: {          
                    Authorization: `Bearer ${accessToken}`
                }})
                .then(response => {
                  let docs: IDocument[] = [];
                  console.log(response.data.value);
                  response.data.value.forEach(element => {
                    docs.push({
                      name: element.fields.FileLeafRef,
                      description: element.fields.Description0,
                      author: element.createdBy.user.displayName,
                      url: element.webUrl,
                      id: element.id,
                      modified: new Date(element.lastModifiedDateTime),
                      nextReview: new Date(element.fields.NextReview)
                    });
                  });                                            
                res.json(docs);
            }).catch(err => {
                res.status(500).send(err);
            });
        } catch (err) {
            if (err.status) {
                res.status(err.status).send(err.message);
            } else {
                res.status(500).send(err);
            }
        }
    });

  return router;
}

For the backend an own graphRouter is registered in server.ts Once the file method is called at first the bearer authentication token, our ssoToken from the frontend is grabbed and with the exchangeForToken function transformed into an access token with the so called ‘on behalf flow’. Only that access token is able to use the needed permissions and successfully enable the retrieval of site objects such as documents. Having that access token, nothing special anymore, a request for Microsoft Graph is built and executed. The retrieved documents are returned to the frontend.

There the result can be rendered which is established by some components from fluentui/react-northstar which is included in the yeomen teams generator and seems to be going merged with the new fluentui (former Office UI Fabric). Let’s see how it goes but give it a try here to demonstrate the usage of a customized rendering.

import { Provider, Flex, Header, List, RedbangIcon } from "@fluentui/react-northstar";

public render() {
    let listItems: any[] = [];
    if (this.state.documents) {
      this.state.documents.forEach((doc) => {
        let urgentLimit = new Date();
        urgentLimit.setDate(urgentLimit.getDate() - 7);
        const urgent: boolean = doc.nextReview < urgentLimit;
        listItems.push({
            key: doc.id,
            header: doc.name,
            media: urgent ? <RedbangIcon /> : null,
            important: urgent,
            headerMedia: doc.nextReview.toLocaleDateString(),
            content: doc.description
        });
      });
    }
    return (
      <Provider theme={this.state.theme}>
        <Flex fill={true} column styles={{
            padding: ".8rem 0 .8rem .5rem"
        }}>
          <Flex.Item>
            <div>
              <Header content="Documents for review: " />
              <List selectable
                      selectedIndex={this.state.selectedListItem}
                      onSelectedIndexChange={this.listItemSelected}
                      items={listItems}
                      />
            </div>
          </Flex.Item>
        </Flex>
      </Provider>
    );
  }

The items are rendered here in a List and marked ‘special’ with a ! in case they are 7 days overdue. This will look like this:

The task module showing list of documents for review

Once a document is picked this will be returned as an AdaptiveCard to the message with the listItemSelected function:

private listItemSelected = (e, newProps) => {
    const selectedDoc = this.state.documents.filter(doc => doc.id === newProps.items[newProps.selectedIndex].key)[0];
    microsoftTeams.tasks.submitTask({
        doc: selectedDoc
    });
    this.setState({
      selectedListItem: newProps.selectedIndex
    });
  
The result – A document for review as an adaptive card

The main aspects are covered now. Compared to the search based variant the main logic takes place in the frontend now and except some similar things such as configuration settings in the main backend component nothing special happens anymore:

export default class DocReviewActionExtensionMessageExtension implements IMessagingExtensionMiddlewareProcessor {

    public async onFetchTask(context: TurnContext, value: MessagingExtensionQuery): Promise<MessagingExtensionResult | TaskModuleContinueResponse> {
     
      return Promise.resolve<TaskModuleContinueResponse>({
        type: "continue",
        value: {
            title: "Input form",
            url: `https://${process.env.HOSTNAME}/docReviewActionExtensionMessageExtension/action.html`
        }
      });
    }

    public async onSubmitAction(context: TurnContext, value: TaskModuleRequest): Promise<MessagingExtensionResult> {
      const card = CardFactory.adaptiveCard(
      {
        type: "AdaptiveCard",
        body: [
          {
            type: "ColumnSet",
            columns: [
                 ....
            ]
          }
        ],
        $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
        version: "1.0"
      });
      return Promise.resolve({
          type: "result",
          attachmentLayout: "list",
          attachments: [card]
      } as MessagingExtensionResult);
    }

The entry point is the onFetchTask function here. Except the check for configuration (already known from part IV and not different here) it only returns the frontend task module covered above. And the submitAction does nothing else than transforming the picked document object to an adaptive card and give back.

But what if there also is some backend action? As known from part II the capability to mark a document for review was already implemented. So in this case the need for same backend authentication as in part II would be necessary as a potential action click is totally decoupled from the shown task module.

Update: You can also use another task module and handle auth/access to Graph in the frontend as shown above. In my SPFx variant I am doing exactly that.

For a full reference of this action based messaging extension please refer to the github repository. As an outlook to the next option: Since SPFx 1.11 you can also use SharePoint Framework for the task module, that is the frontend component shown here. This further simplifies the authentication mechanism to access Microsoft Graph or other 3rd party APIs. My intention is to adapt this example to that option as well quite soon. Stay tuned. I meanwhile added this as an inofficial part to this series. Inofficial because one main aspect in this series, the authentication, does not really take place there anymore. But the SPFx approach also has downsides why this post still has it’s validity.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 and SharePoint Online development. He loves the new SharePoint Framework as well as some backend stuff around Azure Automation or Azure Functions and also has a passion for Microsoft Graph.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
Although if partially inspired by his daily work opinions are always personal.

Using MSAL.js 2.0 in SharePoint Framework (SPFx)

Using MSAL.js 2.0 in SharePoint Framework (SPFx)

Since Jul-20 this year the MSAL.js 2.0 library is generally available (GA). This is a big step forward as there are still issues in authentication and authorization with Azure AD applications such as Microsoft Graph. For instance using Safari Browser and the Intelligent Tracking Protection (ITP) which will not work with implicit flow or iFrames as used by SharePoint Framework (SPFx). The ‘AADSTS50058: A silent sign-in request was sent but no user is signed in.‘ is a typical error in this scenario.

The alternative is MSAL.js 2.0 using the authorization code flow where this small little post wants to show how to implement it.

Preparation

Once we have setup our standard webpart we need to install MSAL.js 2.0 by

npm install @azure/msal-browser

Next we need to register an application in Azure AD.

What’s important here, is to use “Single-page-application (SPA) and give it a Redirect URI. This should match your page later where you want to instantiate your webpart (Hey this sounds like showstopper?) and is case sensitive but in fact it must only match your request (incl. wildcard option) and the Uri needs to be a valid one otherwise you would receive a timeout.

In case you have third party cookies disabled AND cannot use a popup to verify user login you would need a redirect: The Url must match your page with your webpart.

For details on redirect Uri restrictions click here.

From the app registration the app id, redirect uri and tenant url will be needed later on. Last point here is granting some permissions. In this demo case Mail.Read is sufficient.

To achieve a 100% silent retrieval an admin consent for the permission is already granted. The code of this solution does not handle user consent.

Basic implementation

Now implementation can start. First, some values, needed for MSAL, can be made configurable. Therefore webpart properties are adjusted:

export interface IMyMailsWebPartProps {
  applicationID: string;
  redirectUri: string;
  tenantUrl: string;
}
// ...
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
    return {
      pages: [
        {
          header: {
            description: strings.PropertyPaneDescription
          },
          groups: [
            {
              groupName: strings.BasicGroupName,
              groupFields: [
                PropertyPaneTextField('applicationID', {
                  label: strings.ApplicationIDFieldLabel
                }),
                PropertyPaneTextField('redirectUri', {
                  label: strings.RedirectUriFieldLabel
                }),
                PropertyPaneTextField('tenantUrl', {
                  label: strings.TenantUrlFieldLabel
                })
              ]
            }
          ]
        }
      ]
    };
  }

This properties also need to be given to the top level React component but additionally needed are current user’s email and the HttpClient used to retrieve Microsoft Graph data.

import { HttpClient } from "@microsoft/sp-http";

export interface IMyMailsProps {
  applicationID: string;
  redirectUri: string;
  tenantUrl: string;
  userMail: string;
  httpClient: HttpClient;
}

First in the constructor the MSAL client can be established:

private myMSALObj: PublicClientApplication;

  constructor(props) {
    super(props);

    const msalConfig = {
      auth: {
        authority: `https://login.microsoftonline.com/${this.props.tenantUrl}`,
        clientId: this.props.applicationID,
        redirectUri: this.props.redirectUri
      }
    };
    
    this.myMSALObj = new PublicClientApplication(msalConfig);
    
    this.state = {
      mails: []
    };
    ....
  }

Now everything is ready to use. At first a check is made if there are any users already authenticated. If so only an access token needs to be acquired to retreive mails. If not, a login will be tried on several options and then the request for current user’s emails is executed towards Microsoft Graph.

private loadMails = () => {
    const accounts = this.myMSALObj.getAllAccounts();
    if (accounts !== null) {
      this.handleLoggedInUser(accounts);
    }
    else {
      this.loginForAccessTokenByMSAL()
      .then((token) => {
        this.getMailsFromGraph(token).then(mails => {
          this.setState(() => {
            return { mails: mails };
          });      
        });
      });
    }    
  }

Authenticated user: Acquire token

In case a user was already authenticated (and is stored in cache, by default in sessionStorage but this is configurable in “our” msalConfig) just a check is needed to evaluate the right one and then the access token for that case can be acquired (from cache as well or refreshed, MSAL will handle). With a given access token user’s mails can be retrieved.

private handleLoggedInUser(currentAccounts: AccountInfo[]) {
    let accountObj = null;
    if (currentAccounts === null) {
      // No user signed in
      return;
    } else if (currentAccounts.length > 1) {
        // More than one user is authenticated, get current one 
        accountObj = this.myMSALObj.getAccountByUsername(this.props.userMail);
    } else {
        accountObj = currentAccounts[0];
    }
    if (accountObj === null) {
      this.acquireAccessToken(this.ssoRequest, accountObj)
      .then((accessToken) => {
        this.getMailsFromGraph(accessToken).then(mails => {
          this.setState(() => {
            return { mails: mails };
          });      
        });
      });
    }    
  }

No authenticated user: Login

If no authentication took place so far the loginForAccessTokenByMSAL function is tried silently. If this is not successful cause “InteractionRequired” another attempt by a popup is tried, finally also the redirect option can be tried (see below).

private ssoRequest: AuthorizationUrlRequest = {
    scopes: ["https://graph.microsoft.com/Mail.Read"]    
  };

private async loginForAccessTokenByMSAL(): Promise<string> {
    this.ssoRequest.loginHint = this.props.userMail;  
    return this.myMSALObj.ssoSilent(this.ssoRequest).then((response) => {
      return response.accessToken;  
    }).catch((error) => {  
        console.log(error);
        if (error instanceof InteractionRequiredAuthError) {
          return this.myMSALObj.loginPopup(this.ssoRequest)
          .then((response) => {
            return response.accessToken;
          }) 
          .catch(error => {
            if (error.message.indexOf('popup_window_error') > -1) { // Popups are blocked
              return this.redirectLogin(this.ssoRequest);
            }            
          });
        } else {
            return null;
        }
    });  
  }

But even in private sessions or generally with third party cookies turned off, as a login is in place inside SPFx a popup or redirect window do not need interaction but close automatically within 1s. This is because SPFx already established a user session.
Once the token is available either from silent or popup login a request towards Microsoft Graph is nothing special anymore. Only the Header with bearer token authorization needs to be constructed and then the correct endpoint can be used:

private getMailsFromGraph = async (accessToken: string): Promise<any> => {
    if (accessToken !== null) {
      const graphMailEndpoint: string = "https://graph.microsoft.com/v1.0/me/messages";
      return this.props.httpClient
        .get(graphMailEndpoint, HttpClient.configurations.v1,
          {
            headers: [
              ['Authorization', `Bearer ${accessToken}`]
            ]
          })
        .then((res: HttpClientResponse): Promise<any> => {
          return res.json();
        })
        .then((response: any) => {
          let mails: IMail[] = [];
          response.value.forEach((m) => {
            mails.push({from: m.from.emailAddress.address, subject: m.subject});
          });
          return mails;
        });
      }
      else {
        console.log("Error retrieving token");
        return [];
      }
  }

The webpart

That’s all. The simple ‘Hello World’ webpart could be adapted now like this:

Finally a click on the button ‘Get mails’ will execute the loadMails function from above and some mails will be rendered:

Login by page redirect

A redirect works slightly different. The function initiates a redirect to a Microsoft login page. But as said a user session is already there with SPFx so it gets directly back to the given redirectURI which is now essential in this case that it gets back to the correct SP page. Now the situation needs to be handled, which could be done in the constructor as such a redirect totally reloads the page (!!).

constructor(props) {
    ...
    this.myMSALObj.handleRedirectPromise().then((tokenResponse) => {      
      if (tokenResponse !== null) {
        const access_token = tokenResponse.accessToken;
        this.getMailsByMSAL(access_token).then(mails => {
          this.setState(() => {
            return { mails: mails };
          });      
        });
      } else 
      {
         // In case we would like to directly load data in case of NO redirect:
        // const currentAccounts = this.myMSALObj.getAllAccounts();
        // this.handleLoggedInUser(currentAccounts);
      }      
    }).catch((error) => {
        console.log(error);
        return null;
    });
  }

So what happens exactly in this “handleRedirectPromise” and where does the token come from? If a debugger would stop right here or this code wouldn’t exist (comment out for instance) in the browser window could be detected the following in the Url:

Page redirect – The authorization code

What’s retrieved by the redirect url as an anchor is the so called authorization code. MSAL will in a next step use this to retrieve an access token. So after the code is finished a “normal” url can be detected:

Page redirect – After authorization code handled

SPA vs webpart on portal pages considerations

Originally MSAL.js 2.0 is dedicated to single page applications (SPA). So what about the scenario we have multiple webparts (same or different) on a same page using that scenario? Especially with the above described redirect login it can cause strange situations having several webparts or instances on the same page. Try it out with this example.

You would need to insure that each page only has one “primary” webpart which is responsible for establishing the login. All the other webparts consuming MSAL tokens then should be enforced to only “acquire” an access token. Furthermore if there are different webparts on the page with different scopes and it’s controllable it would make sense to summarize required permissions under the same app id as only then the scenario (one webpart only responsible for login) makes sense.

Hope this post helps a bit to understand and implement even more complex scenarios with SPFx and MSAL.js 2.0. Of course it’s not the one and only answer on issues with the standard way of SPFx accessing 3rd party Apis with MSGraphClient  or AadHttpClient but in many scenarios it might be a suitable solution.
For your reference there is also a github repository with the full code example.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 and SharePoint Online development. He loves the new SharePoint Framework as well as some backend stuff around Azure Automation or Azure Functions and also has a passion for Microsoft Graph.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
Although if partially inspired by his daily work opinions are always personal.

A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph IV

A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph IV

I recently started to deep-dive in Microsoft Teams development and I believe it will become a big thing especially around “integration”. But what’s one of the main points when it comes to integrate systems, it’s all about authentication. This is missing in the very basic starter documentation and that’s for reasons. Outside the SPFx world it’s not one of the easiest tasks.

My use case scenario is:
Assume you have some documents out there that need a review from time to time and those that are “due” you wanna share inside Microsoft Teams. So a user can select inside his channel from all retrieved due documents to post them as a card into the current channel. From there you can view them and even confirm the review.

Search Based Messaging Extension to review documents

This is a little series. In this fourth part part we will have a look how to deal with configuration values.

A solution needs to be configurable, at least often this makes sense. This little post will show how this can be achieved for a Teams Messaging Extension. In Part I.I when we created our content we also put two values in our .env file for demonstration purposes only. Assume this solution to be productive and reusable for different teams with different content. In such a situation app config doesn’t work anymore. We need something more user or context specific.

If it’s specified in the app manifest that a messaging extension is configurable (“canUpdateConfiguration”: true)

"composeExtensions": [
    {
      "botId": "{{MICROSOFT_APP_ID}}",
      "canUpdateConfiguration": true,
      "commands": [
        {
          "id": "documentReviewMessageMessageExtension",
          "title": "Document Review Message",
          "description": "Add a clever description here",
          "initialRun": true,
          "parameters": 
....

User can modify settings the following ways (depending if it’s from the compose box or command box).

Once “Settings” is clicked the onQuerySettingsUrl in our TeamsMessagingExtension middleware is called.

public async onQuerySettingsUrl(context: TurnContext): Promise<{ title: string, value: string }> {
        const configFilename = process.env.CONFIG_FILENAME;
        const settings = new JsonDB(configFilename ? configFilename : "settings", true, false);
        let siteID: string;
        let listID: string;
        try {
            siteID = settings.getData(`/${context.activity.channelData.tenant.id}/${context.activity.channelData.team.id}/${context.activity.channelData.channel.id}/siteID`);
            listID = settings.getData(`/${context.activity.channelData.tenant.id}/${context.activity.channelData.team.id}/${context.activity.channelData.channel.id}/listID`);
        } 
        catch (err) 
        {
            siteID = process.env.SITE_ID ? process.env.SITE_ID : '';
            listID = process.env.LIST_ID ? process.env.LIST_ID : '';
        }   
        return Promise.resolve({
            title: "Document Review Message Configuration",
            value: `https://${process.env.HOSTNAME}/documentReviewMessageMessageExtension/config.html?siteID=${siteID}&listID=${listID}`
        });
    }

The main exercise of this method is (to find at the end) to return a UI where the user can enter/select/maintain the configuration values. That is the config.html page in this case. The code before simply retrieves existing values and provides them as search query parameters to the Url. Details about retrieval/storage option used here see below when saving the config.

The config.html is mainly implemented in a TypeScript React part:

export class DocumentReviewMessageMessageExtensionConfig extends TeamsBaseComponent<IDocumentReviewMessageMessageExtensionConfigProps, IDocumentReviewMessageMessageExtensionConfigState> {

    public componentWillMount() {
        this.updateTheme(this.getQueryVariable("theme"));

        const urlParams = new URLSearchParams(window.location.search);
        const siteID = urlParams.get('siteID');
        const listID = urlParams.get('listID');
        this.setState({
            siteID: siteID ? siteID : "",
            listID: listID ? listID : ""
        });
        microsoftTeams.initialize();
        microsoftTeams.registerOnThemeChangeHandler(this.updateTheme);
        microsoftTeams.appInitialization.notifySuccess();
    }

    /**
     * The render() method to create the UI of the tab
     */
    public render() {
        return (
            <Provider theme={this.state.theme}>
                <Flex fill={true}>
                    <Flex.Item>
                        <div>
                            <Header content="Document Review Message configuration" />
                            <Label>Site ID: </Label>
                            <Input
                                placeholder="Enter a site ID here"
                                fluid
                                clearable
                                value={this.state.siteID}
                                onChange={(e, data) => {
                                    if (data) {
                                        this.setState({
                                            siteID: data.value
                                        });
                                    }
                                }}
                                required />
                            <Label>List ID: </Label>
                            <Input
                                placeholder="Enter a list ID here"
                                fluid
                                clearable
                                value={this.state.listID}
                                onChange={(e, data) => {
                                    if (data) {
                                        this.setState({
                                            listID: data.value
                                        });
                                    }
                                }}
                                required />
                            <Button onClick={() =>
                                microsoftTeams.authentication.notifySuccess(JSON.stringify({
                                    siteID: this.state.siteID,
                                    listID: this.state.listID
                                }))} primary>OK</Button>
                        </div>
                    </Flex.Item>
                </Flex>
            </Provider>
        );
    }
}

This React component first retrieves the two config values from the url search query and puts them in the state. In the render method two Text Inputs and a Button are rendered. The button simply submits the values from the state which are corresponding with the Input values.

Once the submit is clicked we are back in our middleware. This time in the OnSettings function:

public async onSettings(context: TurnContext): Promise<void> {
        // take care of the setting returned from the dialog, with the value stored in state
        const setting = JSON.parse(context.activity.value.state);
        log(`New setting: ${setting}`);
        const configFilename = process.env.CONFIG_FILENAME;
        const settings = new JsonDB(configFilename ? configFilename : "settings", true, false);
        settings.push(`/${context.activity.channelData.tenant.id}/${context.activity.channelData.team.id}/${context.activity.channelData.channel.id}`, setting, false);
        return Promise.resolve();
    }

For our simple purposes a JsonDB is used. That is a simple file which contains Json data and can contain (DB like) multiple values to be looked up by a ‘key’. As ‘key’ we use a string concatenation of the current tenant id, the team-id and the channel-id. This enables the solution to have different configuration per each channel where it’s established / used. If the channel-id would be omitted a config would be valid for the whole team for instance.

Once the setting is stored the Json file can be located in the root directory of your solution (assuming a test-run with ngrok and given a simple filename). To make this even work in an Azure Function it’s needed to be overwritten with a whole path like D:\Home\<filename>.json for instance as in the solution directory write operations wouldn’t be possible. That’s why the filename is turned into an environment variable (process.env.CONFIG_FILENAME).

Of course this is a simple solution that might not meet production scenarios. In that case the store / retrieve mechanism might be exchanged by a different and more robust scenario (Azure Cosmos DB e.g.?). But in terms of the architecture of the Teams Messaging Extension and the functions it would still stay the same.

To find the whole solution refer to the github repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 and SharePoint Online development. He loves the new SharePoint Framework as well as some backend stuff around Azure Automation or Azure Functions and also has a passion for Microsoft Graph.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
Although if partially inspired by his daily work opinions are always personal.
A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph III

A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph III

I recently started to deep-dive in Microsoft Teams development and I believe it will become a big thing especially around “integration”. But what’s one of the main points when it comes to integrate systems, it’s all about authentication. This is missing in the very basic starter documentation and that’s for reasons. Outside the SPFx world it’s not one of the easiest tasks.

My use case scenario is:
Assume you have some documents out there that need a review from time to time and those that are “due” you wanna share inside Microsoft Teams. So a user can select inside his channel from all retrieved due documents to post them as a card into the current channel. From there you can view them and even confirm the review.

Search Based Messaging Extension to review documents

This is a little series. In this third part part we will have a look how to deal with the search parameters.

As we can see from aboves screenshot so far we were only retrieving all documents matching our query but omitting the existence of the search field. Let’s see how we can use that.

First a short look into our manifest:

"commands": [
        {
          "id": "documentReviewMessageMessageExtension",
          "title": "Document Review Message",
          "description": "Add a clever description here",
          "initialRun": true,
          "parameters": [
            {
              "name": "parameter",
              "description": "Description of the parameter",
              "title": "Parameter"
            }
          ],
          "type": "query"
        }
      ]

“initialRun”: true means that the command is immediately executed. In part II in our OnQuery function we had something like this:

if (query.parameters && query.parameters[0] && query.parameters[0].name === "initialRun") {
            // initial run

   ...
} else {
            // the rest
   ...        
}

As we didn’t really care for the difference in Part II we were doing exactly the same in both branches of the If which of course is … but you see it’s detectable if it’s the initialRun (without parameter, in case enabled by manifest) or not.

So let’s modify our code a bit from Part II. In case of the “initialRun” we still want to retrieve all relevant documents from Graph but this time we will also store them to a local variable. And in case we have no “initialRun” we will simply use the parameter value and and filter our documents from the variable with the retrieved parameter value:

public async onQuery(context: TurnContext, query: MessagingExtensionQuery): Promise<MessagingExtensionResult> {
        const attachments: MessagingExtensionAttachment[] = [];
        const adapter: any = context.adapter;
        const magicCode = (query.state && Number.isInteger(Number(query.state))) ? query.state : '';        
        const tokenResponse = await adapter.getUserToken(context, this.connectionName, magicCode);

        if (!tokenResponse || !tokenResponse.token) {
            // There is no token, so the user has not signed in yet.
            // Omitted for brevity (see Part II)        
        }
        let documents: IDocument[] = [];
        if (query.parameters && query.parameters[0] && query.parameters[0].name === "initialRun") {
            const controller = new GraphController();
            const siteID: string = process.env.SITE_ID ? process.env.SITE_ID : '';
            const listID: string = process.env.LIST_ID ? process.env.LIST_ID : '';
            documents = await controller.getFiles(tokenResponse.token, siteID, listID);
            this.documents = documents;
        }
        else {
            if (query.parameters && query.parameters[0]) {
                const srchStr = query.parameters[0].value;
                documents = this.documents.filter(doc => 
                    doc.name.indexOf(srchStr) > -1 ||
                    doc.description.indexOf(srchStr) > -1 ||
                    doc.author.indexOf(srchStr) > -1 ||
                    doc.url.indexOf(srchStr) > -1 ||
                    doc.modified.toLocaleString().indexOf(srchStr) > -1 
                );
            }            
        }
        documents.forEach((doc) => {
            const today = new Date();
            const nextReview = new Date(today.setDate(today.getDate() + 180));
            const minNextReview = new Date(today.setDate(today.getDate() + 30));
            const card = CardFactory.adaptiveCard(
                {
                   // Create card for each document.
                   // Omitted for brevity (see Part II)
                });            
            const preview = {
                   // Create preview for each document.
                   // Omitted for brevity (see Part II)            };
            attachments.push({ contentType: card.contentType, content: card.content, preview: preview });
        });
        
        return Promise.resolve({
            type: "result",
            attachmentLayout: "list",
            attachments: attachments
        } as MessagingExtensionResult);        
    }

I reduced the code a bit as the Graph and AdaptiveCard stuff is still the same than in Part II. You can also watch the full code in my github repository.

Now our solution works with both an “initialRun” but also a further filtered run as you can see from the screenshots:

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 and SharePoint Online development. He loves the new SharePoint Framework as well as some backend stuff around Azure Automation or Azure Functions and also has a passion for Microsoft Graph.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
Although if partially inspired by his daily work opinions are always personal.
A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph II

A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph II

I recently started to deep-dive in Microsoft Teams development and I believe it will become a big thing especially around “integration”. But what’s one of the main points when it comes to integrate systems, it’s all about authentication. This is missing in the very basic starter documentation and that’s for reasons. Outside the SPFx world it’s not one of the easiest tasks.

My use case scenario is:
Assume you have some documents out there that need a review from time to time and those that are “due” you wanna share inside Microsoft Teams. So a user can select inside his channel from all retrieved due documents to post them as a card into the current channel. From there you can view them and even confirm the review.

Search Based Messaging Extension to review documents

This is a little series. In this second part we will implement the user authentication and the access to Microsoft Graph.

As me you might come from the SPFx technology and maybe, as me, you also started your Teams app journey developing Teams Tabs with SSO and on behalf access to Microsoft Graph. Messaging extensions are a bit different as most parts take place in the backend so also the authentication is slightly different. You cannot really use the Teams frontend context as once a Messaging extension is called the user is directed to the backend. There it can be checked if a sign in already took place. In case not, an action to request a sign in will be returned. Once that is done the “original action” to return search results (we use a search based command in this case) can take place.

public async onQuery(context: TurnContext, query: MessagingExtensionQuery): Promise<MessagingExtensionResult> {
        const attachments: MessagingExtensionAttachment[] = [];
        const adapter: any = context.adapter;
        const magicCode = (query.state && Number.isInteger(Number(query.state))) ? query.state : '';        
        const tokenResponse = await adapter.getUserToken(context, this.connectionName, magicCode);

        if (!tokenResponse || !tokenResponse.token) {
            // There is no token, so the user has not signed in yet.

            // Retrieve the OAuth Sign in Link to use in the MessagingExtensionResult Suggested Actions
            const signInLink = await adapter.getSignInLink(context, this.connectionName);
            let composeExtension: MessagingExtensionResult = {
                type: 'config',
                suggestedActions: {
                    actions: [{
                        title: 'Sign in as user',
                        value: signInLink,
                        type: ActionTypes.OpenUrl
                    }]
                }
            };
            return Promise.resolve(composeExtension);
        }
        const controller = new GraphController();
        const siteID: string = process.env.SITE_ID ? process.env.SITE_ID : '';
        const listID: string = process.env.LIST_ID ? process.env.LIST_ID : '';
        let documents: IDocument[] = await controller.getFiles(tokenResponse.token, siteID, listID);
        documents.forEach((doc) => {
            const card = CardFactory.adaptiveCard(
                {
                    type: "AdaptiveCard",
                    body: [
                        {
                            type: "ColumnSet",
                            columns: [
                                {
                                    type: "Column",
                                    width: 25,
                                    items: [
                                        {
                                            type: "Image",
                                            url: `https://${process.env.HOSTNAME}/assets/icon.png`,
                                            style: "Person"
                                        }
                                    ]
                                },
                                {
                                    type: "Column",
                                    width: 75,
                                    items: [
                                        {
                                            type: "TextBlock",
                                            text: doc.name,
                                            size: "Large",
                                            weight: "Bolder"
                                        },
                                        {
                                            type: "TextBlock",
                                            text: doc.description,
                                            size: "Medium"
                                        },
                                        {
                                            type: "TextBlock",
                                            text: `Author: ${doc.author}`
                                        },
                                        {
                                            type: "TextBlock",
                                            text: `Modified: ${doc.modified.toLocaleDateString()}`
                                        }
                                    ]
                                }
                            ]
                        }                     
                    ],
                    actions: [
                        {
                            type: "Action.OpenUrl",
                            title: "View",
                            url: doc.url
                        },
                        {
                            type: "Action.ShowCard",
                            title: "Review",
                            card: {
                                type: "AdaptiveCard",
                                body: [
                                    {
                                        type: "Input.Text",
                                        isVisible: false,
                                        value: doc.id,
                                        id: "id"
                                    },
                                    {
                                        type: "Input.Text",
                                        isVisible: false,
                                        value: "reviewed",
                                        id: "action"
                                    },
                                    {
                                        type: "Input.Date",
                                        id: "nextReview"
                                    }
                                ],
                                actions: [
                                    {
                                        type: "Action.Submit",
                                        title: "Reviewed"
                                        
                                    }
                                ],
                                "$schema": "http://adaptivecards.io/schemas/adaptive-card.json"
                            }
                        }                        
                    ],
                    $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
                    version: "1.0"
                });            
            const preview = {
                contentType: "application/vnd.microsoft.card.thumbnail",
                content: {
                    title: doc.name,
                    text: doc.description,
                    images: [
                        {
                            url: `https://${process.env.HOSTNAME}/assets/icon.png`
                        }
                    ]
                 
                }
            };
            attachments.push({ contentType: card.contentType, content: card.content, preview: preview });
        });
        

        if (query.parameters && query.parameters[0] && query.parameters[0].name === "initialRun") {
            // initial run

            return Promise.resolve({
                type: "result",
                attachmentLayout: "list",
                attachments: attachments
            } as MessagingExtensionResult);
        } else {
            // the rest
            return Promise.resolve({
                type: "result",
                attachmentLayout: "list",
                attachments: attachments
            } as MessagingExtensionResult);
        }
    }

What we have to change first, is our Messaging Extension Middleware processor. In the case of a search base command regularly the onQuery function will have the first point of contact in regular execution. Here we will implement the authentication. We typecast our botAdapter and then we look into the current query state if there is a Number in that represents an already established authentication. Together with that we try to receive a user token from the botAdapter given the correct connectionName (we established in Part I). If that doesn’t work we will retrieve the corresponding signInLink. This we will return back finally as an action to prompt the user to sign in. It turned out for me that the type: 'config' is the necessary alternative. type: 'auth' was not working although used by other authors I linked in Part I.

Sign In request by the messagin extension (resp. it’s bot)

After the user signed in he will get back directly to the onQuery function. This time he will pass the authentication process. So we can proceed after the return of the Action. We instantiate our GraphController and retrieve file items. This file items we turn into AdaptiveCards which we return back to the user. The ‘Preview’ cards are used in the search result while the regular cards are used once the user picks one to post to the channel.

The next function we implement is the submit action. Once the user clicks “Reviewed”, the messaging extension shall also update the document.

public async onCardButtonClicked(context: TurnContext, value: any): Promise<void> {
        const adapter: any = context.adapter;
        const magicCode = (value.state && Number.isInteger(Number(value.state))) ? value.state : '';        
        const tokenResponse = await adapter.getUserToken(context, this.connectionName, magicCode);

        if (!tokenResponse || !tokenResponse.token) {
            // There is no token, so the user has not signed in yet.            
            return Promise.reject();
        }
        // Handle the Action.Submit action on the adaptive card
        if (value.action === "reviewed") {
            const controller = new GraphController();
            const siteID: string = process.env.SITE_ID ? process.env.SITE_ID : '';
            const listID: string = process.env.LIST_ID ? process.env.LIST_ID : '';
            let nextReview: Date;
            if (value.nextReview === null || value.nextReview === '') {
                const today = new Date();
                nextReview = new Date(today.setDate(today.getDate() + 180));
            }
            else {
                nextReview = new Date(value.nextReview);
            }
            controller.updateItem(tokenResponse.token, siteID, listID, value.id, nextReview.toString());
        }    
        return Promise.resolve();
    }

This is more or less straightforward. We need to get our token again, this time it should be there as without a sign-in we wouldn’t have received our previous result. We collect the data from our Action card and call the GraphController in the backend.

I was struggeling a bit to receive data from our Input field together with some pre-defined values (item ID, action name for verification) so my workaround is simply to put the pre-defined values in hidden input fields:

{
  type: "Action.ShowCard",
  title: "Review",
  card: {
    type: "AdaptiveCard",
    body: [
    {
       type: "Input.Text",
       isVisible: false,
       value: doc.id,
       id: "id"
     },
     {
       type: "Input.Text",
       isVisible: false,
       value: "reviewed",
       id: "action"
     },
     {
       type: "Input.Date",
       id: "nextReview",                                        
       spacing: "Medium"
     }
     ],
     actions: [
     {
       type: "Action.Submit",
       title: "Reviewed"
     }
   ],
   "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
   version: "1.0"
 }
}

Now we are ready to run our stuff. We therefore need start it and install the app to Microsoft Teams. If you use the free temporary Url service from NGrok then I recommend to start two NodeJS command prompts with your solution directory open. In the first run gulp start-ngrok and copy the temp Url you recieve first to your HOSTNAME in the .env and second in the Messaging endpoint Url inside your Bot Channel Registration.

Configure Bot Channel messaging endpoint

In the second afterwards run gulp manifest and then gulp serve --debug.

Now go to your Teams Apps store and install your custom app by uploading the zip package created by gulp manifest inside your solution related \package folder.

Next you need to add your app to a Microsoft Teams Team of your choice and you are ready to test, for instance from the little “box” symbol below:

Invoke your messaging extension

Once you select a document it will present you the following card incl. Actions.

Messaging extension action card

For this time it’s enough I think. Of course there is much space to polish this little demo but the main aspect was to show some idea what’s possible and how it works with authenticating to “another” system (here Microsoft Graph). In further parts I will think about how to implement a “real” search (here it was more a simple retrieval) and a “user configuration to pick the right site(s) where the documents shall come from. Meanwhile you can also refer to my github repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 and SharePoint Online development. He loves the new SharePoint Framework as well as some backend stuff around Azure Automation or Azure Functions and also has a passion for Microsoft Graph.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
Although if partially inspired by his daily work opinions are always personal.
A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph I.I

A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph I.I

I recently started to deep-dive in Microsoft Teams development and I believe it will become a big thing especially around “integration”. But what’s one of the main points when it comes to integrate systems, it’s all about authentication. This is missing in the very basic starter documentation and that’s for reasons. Outside the SPFx world it’s not one of the easiest tasks.

My use case scenario is:
Assume you have some documents out there that need a review from time to time and those that are “due” you wanna share inside Microsoft Teams. So a user can select inside his channel from all retrieved due documents to post them as a card into the current channel. From there you can view them and even confirm the review.

This is a little series. In this little amendment to part I we will quickly create the content.

Therefore I created a little PnP Provisioning template which you can find in my git repository inside the templates folder. Download this to your local machine. Next you might overwrite the parameters inside (for instance with “Shared Documents” and “Documents” in case you have an eng tenant and wanna use the default documents library) and then use PnP PowerShell to apply to a site of your choice:

Coonect-PnPOnline - Url <Your-Site-Url> -Credential Get-Credential
Apply-PnPProvisioningTemplate -Path <Your-Local-Path>/DocReview.xml

After that upload some test documents and set especially the “next review date”.

As long as we won’t have a comfortable configuration solution (coming in a later part!) we would also need to evaluate the site-id and the list id of your documents library of choice. This can be simply done with the Graph Explorer. First we retrieve the site-id by getting the site by server-relative url.

https://graph.microsoft.com/v1.0/sites/<YourTenant>.sharepoint.com:/<YourSiteRelativePath>

We note down the ID of the site and build another request url to get all lists of that site.

https://graph.microsoft.com/v1.0/sites/<YourSiteID>/lists

Now from the result we note down the ID of our list. Both IDs we can insert as temporary workaround in our .env file.

SITE_ID=<yourTenant>.sharepoint.com,d68ebd68-1318-4b51-987b-5190155a833e,16fcdef9-65e3-4786-8ff9-a385f7444845
LIST_ID=0f135c4b-ca75-49bf-b019-b790701581da

Now we are finally ready to implement our first messaging extension to retrieve those files. For this see my part II of this series. Meanwhile you can also refer to my github repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 and SharePoint Online development. He loves the new SharePoint Framework as well as some backend stuff around Azure Automation or Azure Functions and also has a passion for Microsoft Graph.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
Although if partially inspired by his daily work opinions are always personal.
A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph I

A Microsoft Teams Messaging Extension with Authentication and access to Microsoft Graph I

I recently started to deep-dive in Microsoft Teams development and I believe it will become a big thing especially around “integration”. But what’s one of the main points when it comes to integrate systems, it’s all about authentication. This is missing in the very basic starter documentation and that’s for reasons. Outside the SPFx world it’s not one of the easiest tasks.

My use case scenario is:
Assume you have some documents out there that need a review from time to time and those that are “due” you wanna share inside Microsoft Teams. So a user can select inside his channel from all retrieved due documents to post them as a card into the current channel. From there you can view them and even confirm the review.

Search Based Messaging Extension to review documents

This is a little series. In this first part we will setup the project and create / connect the used assets such as app registration, Bot channel and so on.

I started to use the Microsoft Teams App Yeoman generator and was most interested in the concept of Messaging extensions. I learned the basics on authentication from the following three posts:

According to that we have to do some stuff for a good start. At first we create a solution with the yeoman teams generator. For the moment we will use a search based messaging extension. The rest are mostly names and up to you. BotID or Url we can create and insert afterwards.

yo teams – Create a search based messaging extension

Next as typically in projects around Microsoft Graph we need an app registration. We go to our Azure Active Directory via Office 365 Admin portal and register an app registration. We add a client secret and note that down together with the app id. Finally we grant the following delegated permissions. We will mainly use Sites.ReadWrite.All. To retrieve our documents but also to mark them as reviewed and set the next review date.

Graph access app registration – Api permissions

Next we would need to create our Bot. Therefore we go to our Azure portal and Bot Services and create a new “Bot Channels Registration”. A display name of our choice and a temporary “Messaging endpoint” need to be set.

Under “Channels” we need to add “Microsoft Teams” channel. Next to “Microsoft App ID” we click “Manage” and here we can create a secret which we note down for later inserting it into the .env file. And finally we need to set up our OAuth settings, therefore we click on “Add Setting” at the bottom.

Teams Bot OAuth Settings

We insert our created App ID from above together with the corresponding client secret, our tenant ID as well as the scope. https://graph.microsoft.com/.default means we want exactly the same scope we previously granted permissions to that app id.

Teams Bot – Test OAuth connection

Now we are done with configuration of our assets. It’s time to make them usable in our project. Therefore we have the .env file where we need to store our Bot Microsoft App ID, it’s corresponding secret (not to be mixed up with the OAuth settings, here it’s that one we created before via “Manage”) and also the connection name:

# App Id and App Password for the Bot Framework bot
MICROSOFT_APP_ID=0a0ef668-f759-42fa-9cf0-fc4d21fbf286
MICROSOFT_APP_PASSWORD=

# Bot connection
ConnectionName=CustomGraphConnection

To better understand the difference, there is the Bot App ID (MICROSOFT_APP_ID in .env) which is used for the Teams app to communicate with the bot, a necessary operation in any case of a Messaging extension. While the OAuth settings are used that the Bot can communicate with Microsoft Graph and create an on behalf token.

One final thing we also have to adjust in our Teams manifest although most of it is driven by the .env for the moment. One additional valid domain at least is needed as this will be a signInLink we use in Part II.

"validDomains": [
    "{{HOSTNAME}}",
    "*.botframework.com"
  ],

The former step is already ready and we are able to test our app as per the Readme documentation while for the latter step we need some additional implementation. We need to create some content basis in SharePoint and and the authentication code. This will come in the next 2 parts. Meanwhile you can also refer to my github repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 and SharePoint Online development. He loves the new SharePoint Framework as well as some backend stuff around Azure Automation or Azure Functions and also has a passion for Microsoft Graph.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
Although if partially inspired by his daily work opinions are always personal.

Microsoft Teams App Yeoman Generator – Split ngrok & serve

In the recent weeks I started to deep-dive into Microsoft Teams development ‘outside’ the SPFx world to prepare for new engagements in that direction.

Once you created a project and are ready to test it ‘locally’ the documentation says, run

gulp ngrok-serve

to start your local web server process and an local ngrok channel to enable Teams to reach your local web server (which directly wouldn’t be possible).

Not only when you are like me: A ‘dumb little idiot’ stepping into every pitfall Teams development presents to you there is a big disadvantage in this.

As ngrok assigns you a random url such as 1ab2b41beffb.ngrok.io the problem is you will lose that url once the whole process stops. When you restart with gulp ngrok-serve you will receive another url. Depending on the project you currently run you will again have to insert that new url in your corresponding .env file, app registration, connector, bot channel …

This can be annoying especially when you are in an experimental phase.

The good thing is there is a workaround: You simply need to split the jobs into two. Then your ngrok part can run the whole day long and at night even with ‘lights on’ … (german phrase)

It will always build a channel to your local web server running on the port that yeoman teams generator assigned to it. Assume you do not change that manually from project to project (why would you?) you can even change the project and still have the same Url serving it, cool in multiple demos.

So what to do?

When you have a look in the local gulpfile.js you will detect what ngrok-serve does and this is what you simply have to split up.

task('ngrok-serve', series('start-ngrok', 'manifest', 'serve'));

You simply open two Node.js command prompts and switch to your local solution directory. In the first window you run

gulp start-ngrok

This window you won’t touch now your whole working day except for copying the temp. url maybe …
In your second window now you can run the rest. First you need to run

gulp manifest

to create your Teams app package with your manifest. This step you only have to repeat once you change things in your project that have an effect on the manifest.

After that you run

gulp serve --debug

Of course you can omit the –debug but assume you want to have your local debugger running. This process now you can stop and restart as often as you want. For instance also when it ‘crashes’ which typically can happen due to memory issues such as this one

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed – JavaScript heap out of memory

Such an error (although it can be fixed as well of course) won’t be a big deal anymore as you can simply re-start your solution and it will still be available via the same temp. ngrok url from the start of your day.

Hope this helps you to save some time while playing around with the great and interesting features of Microsoft Teams development.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 and SharePoint Online development. He loves the new SharePoint Framework as well as some backend stuff around Azure Automation or Azure Functions and also has a passion for Microsoft Graph.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
Although if partially inspired by his daily work opinions are always personal.
An Outlook Add-in with SharePoint Framework (SPFx) – Store custom metadata with your mail

An Outlook Add-in with SharePoint Framework (SPFx) – Store custom metadata with your mail

Since SharePoint Framework version 1.10 you can also develop Office Add-Ins with SPFx starting with Outlook Web Access in public preview. The great benefit is you already have the prepared context for access of Microsoft Graph. In a demo scenario I showed you how to create a valuable Outlook Add-In with the capability to store complete mails to OneDrive, Office 365 Groups or Microsoft Teams.

Recently I presented that solution in the Microsoft SharePoint Framework bi-weekly community call.

During the call in the chat the idea was born to enhance this solution with some metadata. In fact to store with the mail so it is persisted by when this mail was saved where. Many thanks to Neelamugilan Gobalakrishnan and my colleague and MVP Wictor Wilen for your feedback on this.

Now I want to show you a simple solution for that. The existing project will only be enhanced by a simple operation to save an openExtension to the message. And when we open the Add-In we first try to get an existing openExtension for that mail and display as a reminder when and where we previously saved that mail in case that openExtension exists.

Now why an open extension you might think? Of course it depends if you use the (more simple) scenario of an open extension or the more complex but also more valuable schema extension scenario.

As per this great post from Mikael Svenson I have three arguments for the choice of open extensions:

  • I do not want to run a filter query on my messages based on the open extensions
  • I do not need my schema going public
  • I do not want that metadata schema being irreversible stored (you can only deprecate it for reasons once ‘Available’)

So lets start with the expected result. Once we open our existing add-in to store our mail somewhere we might want to have a hint like this that we already stored our mail previously and where:

Outlook add-in informs you about previous save operation

To achieve this we first need to store that information during our save process. In our controller we create another function that stores our metadata as an open extension to that mail. This works simply like that:

export default class GraphController {
private client: MSGraphClient;
private metadataExtensionName = 'mmsharepoint.onmicrosoft.MailStorage';
public saveMailMetadata(mailId: string, displayName: string, url: string, savedDate: Date) {
const apiUrl = `/me/messages/${mailId}/extensions`;
const metadataBody = {
"@odata.type" : "microsoft.graph.openTypeExtension",
"extensionName" : this.metadataExtensionName,
"saveDisplayName" : displayName,
"saveUrl" : url,
"savedDate" : savedDate.toISOString()
};
this.client
.api(apiUrl)
.version('v1.0')
.post(JSON.stringify(metadataBody))
.then((response) => {
console.log(response);
});
}
}

The function retrieves the id of the mail, a displayName, an url of the target location and finally a date. The displayName we construct from the target (OneDrive or Group/Team name) combined with the path we already displayed in our breadcrumb. That’s all for the metadata storage.

private saveMailTo = () => {
this.setState((prevState: IOneDriveState, props: IOneDriveProps) => {
return {
showSpinner: true
};
});
this.props.graphController.retrieveMimeMail(this.state.parentFolder.driveID, this.state.parentFolder.id, this.props.mail, this.saveMailCallback)
.then((response: string) => {
const saveLocationDisplayName = `OneDrive …> ${this.state.grandParentFolder.name} > ${this.state.parentFolder.name}`;
this.props.graphController.saveMailMetadata(this.props.mail.id, saveLocationDisplayName, this.state.parentFolder.webUrl, new Date());
});
}

Now back to the start of our add-in where the requirement is to retrieve potentially existing metadata to remind the user in case the mail was already stored somewhere. In our base component we try to get the metadata once our graphController is established. As per the get an existing openExtension documentation you can either get the specific extension or expand the extension when getting a known instance, that is your mail. We will do the latter one as we do not know if the extension exists at all and in the former option a non existing extension would raise an error (which we could handle but that’s not the elegant way of course).

private getMetadata() {
this.state.graphController.retrieveMailMetadata(this.props.mail.id)
.then((response) => {
if (response !== null) {
this.setState((prevState: IOutlook2SharePointState, props: IOutlook2SharePointProps) => {
return {
mailMetadata: response
};
});
}
});
}
export default class GraphController {
private client: MSGraphClient;
private metadataExtensionName = 'mmsharepoint.onmicrosoft.MailStorage';
public retrieveMailMetadata(mailId: string): Promise<any> {
const apiUrl = `/me/messages/${mailId}`;
const expand = `Extensions($filter=id eq 'Microsoft.OutlookServices.OpenTypeExtension.${this.metadataExtensionName}')`;
return this.client
.api(apiUrl)
.version('v1.0')
.expand(expand)
.select('id,subject,extensions')
.get()
.then((response) => {
if (typeof response.extensions !== 'undefined' && response.extensions !== null) {
const metadata: IMailMetadata = {
extensionName: response.extensions[0].extensionName,
saveDisplayName: response.extensions[0].saveDisplayName,
saveUrl: response.extensions[0].saveUrl,
savedDate: new Date(response.extensions[0].savedDate)
};
return metadata;
}
else {
return null;
}
},
(error) => {
console.log(error);
return null;
});
}
}

The graph request tries to retrieve the mail again only with id and subject but also with the expanded extension. In case there is metadata we return that as an object otherwise null. The result will be written to the components state and rendered in case it is not null.

Display in case metadata is filled
Display in case metadata is null

That’s all with my little enhancement using custom metadata for mails based on Microsoft Graph open extensions for my Outlook add-in.
You can check the full code repository here in my GitHub.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 and SharePoint Online development. He loves the new SharePoint Framework as well as some backend stuff around Azure Automation or Azure Functions and also has a passion for Microsoft Graph.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
Although if partially inspired by his daily work opinions are always personal.