Delegate management module updated

The module has been updated to version 1.5.1. This version adds automatic support for localization of the Sent Items and Deleted Items folders. If the display name of those folders in the owner’s mailbox is not in English, the localized display name of the folder will be used when getting, setting, or removing delegates.

I have also added permission validation to the owner’s mailbox for the person executing a cmdlet. When using impersonation, if you don’t have permission to a mailbox Exchange responds with an error indicating as much. But if using full access, Exchange doesn’t respond with such an error, just failing on whatever request is being made. Usually when permission is the issue, the error contains “The specified object was not found in the store,” so the module checks for that error, informs you that it appears you don’t have permission, and then gracefully aborts the cmdlet.

Download the updated module and overwrite your existing copy.  If you were already using v1.5.0, keep your existing settings file so your specific settings remain.

  DelegateManagement.zip (9.2 KiB)

New form for creating travel time appointments in Outlook

Articles in the "Outlook Travel Time Appointments" series

  1. Add travel time appointments in Outlook
  2. New form for creating travel time appointments in Outlook [This article]

I still use my code to create travel time appointments in Outlook.  I updated it a little while ago, though, to streamline it.  It now uses a VBA form to ask for the travel from and travel to times in one dialog:

The travel time form now lets you create both appointments from one dialog.

This saves a little real estate in the ribbon because now you only need one button:

The custom button runs the macro for launching the form and creating the appointments.

It also saves time because you can have it create both appointments in one go.  If you don’t need the to or from appointment created, leave its field blank.  You can download the two form files below, extract them anywhere, then in the VBA editor you can click File->Import File… and select the .frm file.

  TravelTimeForm.zip (1.6 KiB)

You can copy the updated macros below and paste them into ThisOutlookSession in the VBA editor (and delete the macros from the old version).  The OpenOutlookFolder macro hasn’t changed, but is included for convenience.  The CreateTravelAppointment macro is now called CreateAppointment and the only change is that it sets the appointment’s category to Travel.  The CreateTravelToAppointment and CreateTravelFromAppointment macros are now combined into one called CreateTravelAppointment.  This is the macro you want your ribbon button to execute.

Function OpenOutlookFolder(ByVal strPath As String) As Object
    Dim objSession As NameSpace
    Dim arrFolders As Variant, varFolder As Variant, bolBeyondRoot As Boolean
    
    Set objSession = Outlook.Application.GetNamespace("MAPI")

    On Error Resume Next
    Do While Left(strPath, 1) = "\"
        strPath = Right(strPath, Len(strPath) - 1)
    Loop
    arrFolders = Split(strPath, "\")
    For Each varFolder In arrFolders
        Select Case bolBeyondRoot
            Case False
                Set OpenOutlookFolder = objSession.Folders(varFolder)
                bolBeyondRoot = True
            Case True
                Set OpenOutlookFolder = OpenOutlookFolder.Folders(varFolder)
        End Select
        If Err.Number <> 0 Then
            Set OpenOutlookFolder = Nothing
            Exit For
        End If
    Next
    On Error GoTo 0
    
    Set objSession = Nothing
    
End Function
Sub CreateAppointment(path, subject, starttime, duration, setreminder)
    Dim objCalFolder As Outlook.Folder
    Dim objCalItem As Outlook.AppointmentItem
    
    'Get folder object for given path
    Set objCalFolder = OpenOutlookFolder(path)
    'Create appointment item and set properties
    Set objCalItem = objCalFolder.Items.Add
    objCalItem.subject = subject
    objCalItem.Start = starttime
    objCalItem.duration = duration
    objCalItem.BusyStatus = olOutOfOffice
    objCalItem.Categories = "Travel"
    'Don't set reminder for return travel time
    If setreminder = False Then
        objCalItem.ReminderSet = False
    End If
    objCalItem.Save
    
    Set objCalItem = Nothing
    Set objCalFolder = Nothing
End Sub
Sub CreateTravelAppointment()
    Dim objExplorer As Outlook.Explorer
    Dim objSelection As Outlook.Selection
    Dim objSelectedAppointment As Outlook.AppointmentItem
    Dim strFolderPath As String, intMinutes As Integer, dtStartTime As Date, strSubject As String
    Dim TravelTimeTo As Integer, TravelTimeFrom As Integer
    
    'Get currently selected appointment item and the calendar it is in
    Set objExplorer = Outlook.ActiveExplorer
    Set objSelection = objExplorer.Selection
    'Get path to current calendar folder (allows for working with non-default and additional calendars)
    strFolderPath = objExplorer.CurrentFolder.folderPath
    
    If objSelection.Count <> 1 Then
        noItem = MsgBox("You must first select an appointment item.", vbCritical, "No item selected")
    Else
        Set objSelectedAppointment = objSelection.Item(1)
        'Display form to get travel time durations
        TravelTimeForm.Show
        TravelTimeTo = TravelTimeForm.TravelToTextBox.Value
        TravelTimeFrom = TravelTimeForm.TravelFromTextBox.Value
        'Create travel time appointments only if value provided
        If TravelTimeTo >= 0 Then
            dtStartTime = objSelectedAppointment.Start - TimeSerial(0, TravelTimeTo, 0)
            strSubject = "Travel to " & objSelectedAppointment.subject
            Call CreateAppointment(strFolderPath, strSubject, dtStartTime, TravelTimeTo, True)
            'Disable reminder because travel appointment has one
            objSelectedAppointment.ReminderSet = False
            objSelectedAppointment.Save
        End If
        If TravelTimeFrom >= 0 Then
            dtStartTime = objSelectedAppointment.End
            strSubject = "Travel from " & objSelectedAppointment.subject
            Call CreateAppointment(strFolderPath, strSubject, dtStartTime, TravelTimeFrom, False)
        End If
        Unload TravelTimeForm
    End If
    
    Set objSelectAppointment = Nothing
    Set objSelection = Nothing
    Set objExplorer = Nothing
        
End Sub

Delegate management module updated to v1.5.0

It had been some time since I updated the delegate management PowerShell module. I started to update it last year after I decided to move the admin configuration settings to their own file, but didn’t complete it.  After a user of the module recently contacted me about adding one of the supported options for delegate meeting forwarding, I went back and finished updating it.  These are the changes:

  • Configuration settings have been moved to a file.  This allows for drop-in updates to the module without you having to reconfigure your settings again.  The file, DelegateManagementSettings.xml, should be located in the same directory as the module.  A default file is included in the download, but you can also use the new Set-DelegateManagementSetting cmdlet with the CreateDefaultFile parameter to have the module create one for you.  You can run Get-DelegateManagementSetting to view your current settings.  If you want to change one or more settings for the current session, the Set-DelegateManagementSetting cmdlet can do that, and you can use the Persist parameter if you want the changed setting to be written to the file.  (Comment-based help is available for both new cmdlets, e.g., Get-Help Set-DelegateManagementSetting.)
  • The following functionality has been moved from static values to configurable settings: whether to use Autodiscover, specifying an EWS URL, whether to use impersonation, and if permission should be added/set by default for the Deleted Items and Sent Items folders when adding a delegate.
  • The Azure AD module is no longer needed for managing delegates in Exchange Online.
  • The ability to specify that meeting requests for the owner/manager should not be forwarded to delegates.  (This is supported with Exchange 2013+ and Exchange Online mailboxes.)  NoForward has been added as a valid value with the MeetingRequestDeliveryScope parameter.
  • All functions for connecting via PowerShell to on-premises Exchange and Exchange Online have been removed.  Connection to the appropriate environment should be handled externally to the module, as would usually be done with any other module or cmdlet.  The exception to this is for Exchange Web Services (seeing as it is a stateless connection).  If managing delegates in Exchange Online, the first time you run a delegate cmdlet it will prompt for EWS credentials, which will be used for all future cmdlets in the same shell.  On-premises will use the current user credentials.  If you would like the module to be able to store and recall credentials automatically (in Credential Manager, for example), specify credentials for on-premises, or other credential options, let me know.

The updated module, along with a settings file, is here:

  DelegateManagement.zip (9.2 KiB)

Meeting cancellation script updated

Articles in the "Meeting cancellation script" series

  1. Cancel future meetings in a mailbox
  2. Meeting cancellation script updated [This article]

I have updated the Cancel-MailboxMeetings.ps1 script. These are the changes:

  • Changed the date parameters to align with Microsoft’s Remove-CalendarEvents cmdlet.  StartDate is now QueryStartDate and EndDate is now QueryWindowInDays.  The default time frame is still one year from the query start date, but is now specified as an integer of days instead of a specific date.
  • Added a preview mode via the PreviewOnly switch parameter (also to align with Microsoft).  Using the parameter will list which meetings (by subject and start date, first occurrence start date if a recurring meeting) would be affected.  To provide enough detail, it will indicate if it is a standalone meeting or series and whether the meeting is being canceled (because the mailbox is the organizer) or declined (because the mailbox is an attendee).  If you use the EndOrganizedRecurringMeetings parameter, it will also use wording to show that a meeting series’ end date would be updated instead of canceled.
  • Added detection of modified occurrences that will be lost if a recurring series’ end date is changed.  Because exceptions to a meeting (only modifications, not deletions) are removed when a series’ end date is changed, the script checks for modified occurrences that occurred in the past of the query start date and prompts you to confirm that you want to update that meeting.
  • Added a switch parameter named SuppressLostExceptionPrompt.  Use this if you want to prevent the confirmation prompt about lost modified occurrences and have it change the end date anyway.  This parameter only has an effect if also used with EndOrganizedRecurringMeetings.
  • Although added only while I was testing, but decided to leave in, are some extra details written to the screen if you use the Verbose parameter.

The code in the first post has been updated, as has the downloadable version:

  Cancel-MailboxMeetings.ps1 (11.1 KiB)

Cancel future meetings in a mailbox

Articles in the "Meeting cancellation script" series

  1. Cancel future meetings in a mailbox [This article]
  2. Meeting cancellation script updated

One of the long-running woes of the Exchange admin is that you can’t transfer “ownership” of a meeting to someone else when the owner leaves the company. While you still can’t do that, Microsoft recently announced the Remove-CalendarEvents cmdlet for mailboxes in Exchange Online.  Its intention is to allow the administrator to cancel all future meetings for a terminated employee so that someone else can create new meetings in their place.

Because it works only for cloud mailboxes, I wrote a script to do the same thing for on-premises mailboxes (though it will also work for cloud mailboxes).  It uses EWS to get all future meetings for a mailbox and cancel them.  The default settings are to cancel all meetings that occur in the next year where the mailbox is the organizer.  You can also choose to have it cancel (decline) meetings where the mailbox is an attendee, and you can specify text that should be added to the cancellation or decline message.  Additionally, if you don’t want to wholly cancel recurring meetings because the history of all those occurrences will be lost for the attendees, you can instead have organized recurring meetings end so that only future occurrences are canceled, but historical occurrences remain.

Because of the way calendar items are stored, you can get an item and then see when it occurs, if it is recurring, etc., but if it is a recurring meeting you only see the first occurrence.  To work with occurrences, you use a calendar view (time frame) and let Exchange worry about whether an occurrence of a recurring item should be included in the window.  But you can have multiple occurrences of the same meeting in the time frame, and you want to delete the series, not an occurrence.  In a calendar view, you can see if a meeting is an occurrence and, if so, get the corresponding master for that series.

Once the series is canceled (or ended, if you choose that option), there still can be more occurrences of that canceled series in the search results.  If you try and get the recurring master for an occurrence whose master has already been canceled, you’ll get an error.  So, these occurrences can be skipped, but you have to know which ones can be skipped.  To solve that, I store the recurring meeting’s global object ID (which is the same for a recurring master and all of its occurrences because it is really just one meeting as stored in the calendar) in an array.  For every meeting in the search results that is an occurrence, I check if the global object ID is in the array.  If so, I skip it because its master has already been deleted.  If not, I add the global object ID to the array, then cancel the meeting.

The script has comment-based help so you know what all the parameters are for, and there are inline comments so you can see what is being done throughout.  While you can use current credentials or specify one, if you want it to prompt if you don’t specify credentials and don’t want to use default credentials, you can comment and uncomment the the necessary lines at 81-84.  Likewise for the EWS URL, it is hard-coded to EXO, which you can change, but if you want to use autodiscover, you can comment and uncomment the necessary lines at 91-93.

Download the script via the link below, but you can see the code and copy it below, too.

  Cancel-MailboxMeetings.ps1 (11.1 KiB)

<#
	.Synopsis
		Cancel meetings within a specified timeframe for a mailbox
	.Description
		Ideal for terminated employees, this script will cancel all meetings
		organized by the mailbox, and optionally where the mailbox is an attendee,
		within a given timeframe.  The default is one year from run time.
	.Parameter EmailAddress
		Email address of the mailbox to process.  Pipeline support allows piping
		from Get-Mailbox.
	.Parameter QueryStartDate
		Start date of the timeframe to look for meetings to cancel.  Default is now.
	.Parameter QueryWindowInDays
		Number of days after the start date to look for meetings to cancel.  Default is 365 days.
	.Parameter IncludeAttendeeMeetings
		Switch to also cancel meetings where the mailbox is an attendee
	.Parameter EndOrganizedRecurringMeetings
		Switch to end organized recurring meetings instead of canceling them.
		The end date of the series is set to the cmdlet's StartDate.  This will leave 
		past meeting occurrences on attendee calendars.
	.Parameter SuppressLostExceptionPrompt
		Switch to silently acknowledge that past occurrences of series that have
		been modified will be deleted if EndOrganizedRecurringMeetings is used
	.Parameter PreviewOnly
		Switch to display the meeting subjects and dates that would be canceled
	.Parameter CancellationText
		Text to add to the body of the cancellation message
	.Parameter Credential
		Optional credential object to use instead of asking for or using current credentials
	.Parameter UseImpersonation
		Switch parameter to use impersonation instead of FMA or folder permission
	.Example
		Cancel-MailboxMeetings.ps1 -EmailAdress johndoe@company.com
	.Example
		Cancel-MailboxMeetings.ps1 janedoe@company.com "6/1/17" "8/1/17" -IncludeAttendeeMeetings -CancellationText "Jane is no longer here."
	.Example
		Get-Mailbox johndoe | Cancel-MailboxMeetings.ps1 -Credential (Get-Credential) -EndOrganizedRecurringMeetings
	.Notes
		Required permission: 
			*Editor role to calendar will work for meetings (attendee or organizer)
			- or -
			*Impersonation right to the target mailbox
			- or -
			*Full mailbox access; Send As is not required
		Version: 1.3
		Date: 8/3/17
#>
#Version 3 is only required because of use of the [pscredential] type accelerator for the Credential parameter
#requires -Version 3
[CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact='High')]
Param (
	[Parameter(Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true,HelpMessage="Email address of user")]
		[Alias("PrimarySMTPAddress")][string]$EmailAddress,
	[Parameter(Position=1,Mandatory=$false)][DateTime]$QueryStartDate = (Get-Date),
	[Parameter(Position=2,Mandatory=$false)][int]$QueryWindowInDays = 365,
	[switch]$IncludeAttendeeMeetings,
	[switch]$EndOrganizedRecurringMeetings,
	[switch]$SuppressLostExceptionPrompt,
	[switch]$PreviewOnly,
	[Parameter(Mandatory=$false)][string]$CancellationText,	
	[Parameter(Mandatory=$false)][PSCredential]$Credential,
	[switch]$UseImpersonation
	)

begin
	{
	#Check for EWS API
	$apiPath = (($(Get-ItemProperty -ErrorAction SilentlyContinue -Path Registry::$(Get-ChildItem -ErrorAction SilentlyContinue -Path "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Exchange\Web Services" |
				Sort-Object Name -Descending | Select-Object -First 1 -ExpandProperty Name))."Install Directory") + "Microsoft.Exchange.WebServices.dll")
	if (Test-Path $apiPath)
		{
		Add-Type -Path $apiPath
		}
	else
		{
		Write-Error "The Exchange Web Services Managed API is required to use this script." -Category NotInstalled
		break
		}

	$exchangeVersion = [Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2010_SP2
	$exchangeService = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExchangeService($exchangeVersion) 
	}

process
	{
	if (-not($Credential))
		{
		#$Credential = Get-Credential
		#$exchangeService.Credentials = New-Object -TypeName Microsoft.Exchange.WebServices.Data.WebCredentials($Credential)
		# Or
		$exchangeService.UseDefaultCredentials = $true
		}
	else
		{
		$exchangeService.Credentials = New-Object -TypeName Microsoft.Exchange.WebServices.Data.WebCredentials($Credential)
		}

	#$exchangeService.AutodiscoverUrl($EmailAddress, {$true})
	#Or use hard-coded URL
	$exchangeService.Url = "https://outlook.office365.com/EWS/Exchange.asmx"

	if ($UseImpersonation)
		{
		$exchangeService.ImpersonatedUserId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ImpersonatedUserId(
			[Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, $mailbox.EmailAddress)
		}
	
	#Bind to the calendar
	$folderID = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId(
		[Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::Calendar,$EmailAddress)
	$calFolder = [Microsoft.Exchange.WebServices.Data.Folder]::Bind($exchangeService,$folderID)

	#Create a view to get all events during the specified timeframe
	$StartDate = $QueryStartDate
	$EndDate = $StartDate.AddDays($QueryWindowInDays)
	$calendarView = New-Object -TypeName Microsoft.Exchange.WebServices.Data.CalendarView($StartDate, $EndDate)
	$properties = New-Object -TypeName Microsoft.Exchange.WebServices.Data.PropertySet(
		[Microsoft.Exchange.WebServices.Data.BasePropertySet]::FirstClassProperties)
	#Add Clean Global Object ID to property set so it can be used to correlate occurrences of the same meeting
	$propCleanGOID = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExtendedPropertyDefinition(
		[Microsoft.Exchange.WebServices.Data.DefaultExtendedPropertySet]::Meeting, 35,
		[Microsoft.Exchange.WebServices.Data.MapiPropertyType]::Binary)
	$properties.Add($propCleanGOID)
	$calendarView.PropertySet = $properties
	$searchResult = $exchangeService.FindAppointments($calFolder.Id,$calendarView)

	#region Functions
	function Process-Cancellation ($meeting, $attendeeType, $goid)
		{
		if ($meeting.IsRecurring)
			{
			#Skip processing if recurring master has already been canceled
			if ($meetingIds -notcontains $goid)
				{
				#Determine whether occurrence or master
				if ($meeting.AppointmentType -ne [Microsoft.Exchange.WebServices.Data.AppointmentType]::RecurringMaster)
					{
					#Get recurring master
					$master = [Microsoft.Exchange.Webservices.Data.Appointment]::BindToRecurringMaster($exchangeService,$meeting.Id)
					}
				else
					{
					$master = $meeting
					}
				#Add Global ID to array so remaining occurrences in collection can be ignored
				$meetingIds.Add($goid)
				if ($attendeeType -eq "Organizer")
					{
					#If ending instead of canceling, end only if first occurrence is in the past, else cancel
					if ($EndOrganizedRecurringMeetings -and ($StartDate -gt $master.Start))
						{
						Write-Verbose "EndOrganizedRecurringMeetings is true and first occurrence is in past"
						#Check for modified exceptions that will be lost
						if ($master.ModifiedOccurrences)
							{
							Write-Verbose "Meeting series has modified occurrences"
							$hasException = $false
							foreach ($occurrenceId in $master.ModifiedOccurrences)
								{
								$propertySet = New-Object -TypeName Microsoft.Exchange.WebServices.Data.PropertySet(
									[Microsoft.Exchange.WebServices.Data.BasePropertySet]::IdOnly,
									@([Microsoft.Exchange.WebServices.Data.AppointmentSchema]::Start))
								$occurrence = [Microsoft.Exchange.WebServices.Data.Item]::Bind($exchangeService,$occurrenceId.ItemId,$propertySet)
								if ($occurrence.Start -lt $StartDate)
									{
									Write-Verbose "Meeting series has modified occurrence in the past"
									$hasException = $true
									break
									}
								}
							}
						if ($hasException)
							{
							if ($PreviewOnly)
								{
								Update-MeetingEndDate -meeting $master
								}
							elseif ($PSCmdlet.ShouldProcess("Meeting series with the subject `"$($master.Subject)`" that has one or more past occurrence exceptions that will be lost if the series`' end date is changed") -or $SuppressLostExceptionPrompt)
								{
								Update-MeetingEndDate -meeting $master
								}
							}
						else
							{
							Update-MeetingEndDate -meeting $master
							}
						}
					else
						{
						Send-MeetingCancel -meeting $master 'Series'
						}
					}
				else
					{
					Send-MeetingDecline -meeting $master 'Series'
					}
				}
			}
		else #Non-recurring meeting
			{
			if ($attendeeType -eq "Organizer")
				{
				Send-MeetingCancel -meeting $meeting 'Meeting'
				}
			else
				{
				Send-MeetingDecline -meeting $meeting 'Meeting'
				}
			}
		}

	function Update-MeetingEndDate ($meeting)
		{
		if ($PreviewOnly)
			{
			Write-Output "Series whose end date would be updated: `"$($meeting.Subject)`" on $($meeting.Start.ToShortDateString())"
			}
		else
			{
			$meeting.Recurrence.EndDate = $StartDate
			$meeting.Update([Microsoft.Exchange.WebServices.Data.SendInvitationsMode]::SendToAllAndSaveCopy)
			Write-Output "Series end date updated: $($meeting.Subject)"
			}
		}
	
	function Send-MeetingCancel ($meeting, $type)
		{
		if ($PreviewOnly)
			{
			Write-Output "$type that would be canceled: `"$($meeting.Subject)`" on $($meeting.Start.ToShortDateString())"
			}
		else
			{
			[void]$meeting.CancelMeeting($CancellationText)
			Write-Output "$type canceled: $($meeting.Subject)"
			}
		}
		
	function Send-MeetingDecline ($meeting, $type)
		{
		if ($PreviewOnly)
			{
			Write-Output "$type that would be declined: `"$($meeting.Subject)`" on $($meeting.Start.ToShortDateString())"
			}
		else
			{
			#Decline method does not support custom message, so create decline message
			if ($CancellationText)
				{
				$declineMessage = $meeting.CreateDeclineMessage()
				$declineMessage.Body = $CancellationText
				[void]$declineMessage.Send()
				Write-Output "$type declined: $($meeting.Subject)"
				}
			else
				{
				[void]$meeting.Decline($true)
				Write-Output "$type declined: $($meeting.Subject)"
				}
			}
		}
	#endregion
	
	if ($searchResult.TotalCount -gt 0)
		{
		#Exclude non-meetings
		$meetings = $searchResult.Items | Where-Object {$_.IsMeeting}
		if ($meetings)
			{
			Write-Output "Processing $($meetings.Count) meeting occurrences."
			#Create array to hold list of recurring master IDs
			$meetingIds = New-Object -TypeName System.Collections.Generic.List[System.String]
			foreach ($item in $meetings)
				{
				#Get meeting global ID from extended properties collection
				$cleanGOID = $null
				[void]$item.TryGetProperty($propCleanGOID, [ref]$cleanGOID)
				
				if ($item.MyResponseType -eq [Microsoft.Exchange.WebServices.Data.MeetingResponseType]::Organizer) #Mailbox is organizer
					{
					Process-Cancellation -meeting $item -attendeeType "Organizer" -goid $cleanGOID
					}
				elseif ($IncludeAttendeeMeetings) #Decline meetings where mailbox is attendee
					{
					Process-Cancellation -meeting $item -attendeeType "Attendee" -goid $cleanGOID
					}
				}
			}
		else
			{
			Write-Output "There are no meetings in the specified timeframe."
			}
		}
	else
		{
		Write-Output "There are no meetings in the specified timeframe."
		}
	}

How to pin custom app tiles on behalf of your users in Office 365

The app launcher in Office 365 is how users can quickly get to any workload no matter where they are in Office 365.  It is accessed by clicking the waffle (though I see it more as a keypad) in the upper left corner.  This image is the default tile layout for an E5 admin:

Admins can add custom tiles to the tenant that point to any URL.  These custom tiles then show up under the ALL tab for users.  Here is an example of one I added to my tenant that just points to this blog’s RSS feed (hence the icon):

You may want to not just add the tile, but also pin it to your users’ HOME tab.  Office 365 does not currently allow admins to pin tiles for users; you can only pin apps for your own account.  But that didn’t stop me from figuring out where these settings are stored and manipulating them programmatically.

App launcher settings are stored in a user’s mailbox.  This is why a user needs to have an Exchange Online mailbox in order to customize their app launcher.  The settings for the app launcher are in the PR_ROAMING_DICTIONARY property of the IPM.Configuration.Suite.Storage message at the root folder of the mailbox.  EWS has a class for working with user configuration settings that are stored in a dictionary property, so you don’t have to manually work with the binary property.  Using PowerShell and the EWS Managed API, get the value of this property (the credentials and email address of the mailbox have already been assigned to variables):

$exchangeVersion = [Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2010_SP2
$exchangeService = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExchangeService($exchangeVersion)
$exchangeService.Credentials = New-Object -TypeName Microsoft.Exchange.WebServices.Data.WebCredentials($Credential)
$exchangeService.Url = 'https://outlook.office365.com/ews/exchange.asmx'
$folderId= New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::Root,$MailboxName)       
$userConfig = [Microsoft.Exchange.WebServices.Data.UserConfiguration]::Bind($exchangeService, "Suite.Storage", $folderId, [Microsoft.Exchange.WebServices.Data.UserConfigurationProperties]::All)

The Dictionary property contains a hash table:

and the app launcher settings are stored in the value for a key name of Suite/AppsCustomizationDataTEST.  Because the settings are stored as JSON, let’s convert them to a custom object:

$apps = ConvertFrom-Json $userConfig.Dictionary.Get_Item("Suite/AppsCustomizationDataTEST")

You can see that the all tiles for the Home tab are in a property called PinnedApps, which are themselves stored as custom objects. Here is the first one:In order to pin a tile, you need an object for the one you want to pin.  The easiest way to do this is to manually pin a tile in your app launcher, then use EWS to get that tile object.  Pinning a tile/app adds it to the end of the Home tab as the last item in the collection so, assuming you don’t move it after that, it will be the 24th item in the collection (index 23).  I assigned that item to a variable, so this is the object that will be added to other users’ pinned apps:

The collection of pinned apps is a fixed array, so to add a new item to it, copy the existing array to a new one plus the object for the custom tile.  Then convert the app settings object back to JSON, update the dictionary hash table with the new object, and save the changed user configuration setting back to the server:

$apps.PinnedApps = $apps.PinnedApps += $myapp
$newapps = ConvertTo-Json $apps
$userConfig.Dictionary.Set_Item("Suite/AppsCustomizationDataTEST",$newapps)
$userConfig.Update()

If the user is already logged in, refresh the browser and open the app launcher to see the newly added tile:

You can modify the above code to loop through any number of mailboxes and add the custom tile object to their app launcher.  You can also manipulate the size and placement of the tile if you want, but my example is to show you how it can be added.  It should be noted that, while all of this does work, it is unsupported, so programmatically customize at your own risk.