Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ VITE_DATAVERSE_URL=https://yourorg.crm4.dynamics.com/
VITE_CLIENT_ID=00000000-0000-0000-0000-000000000000
VITE_TENANT_ID=00000000-0000-0000-0000-000000000000
VITE_REDIRECT_URI=http://localhost:5173
# Comma-separated list of shared mailbox addresses shown as instant suggestions in the Inbox tab
# Example: VITE_SHARED_MAILBOXES=support@yourorg.com,sales@yourorg.com
VITE_SHARED_MAILBOXES=
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
<GenerateDataMinerPackage>True</GenerateDataMinerPackage>
<MinimumRequiredDmVersion>10.3.0.0 - 12752</MinimumRequiredDmVersion>
<!-- <MinimumRequiredDmWebVersion>10.6.2 (CU0)</MinimumRequiredDmWebVersion> -->
<Version>1.5.2</Version>
<VersionComment>Inbox-style calendar import workspace with direct Dynamics import</VersionComment>
<Version>1.5.3</Version>
<VersionComment>Mailbox autocomplete picker with People API search, env var allowlist, and localStorage recents</VersionComment>
<!-- Do NOT fill in the Organization Token here. This points to either an Environment Variable skyline__sdk__dataminertoken holding the organization token or a Visual Studio User Secret holding the token. -->
<CatalogPublishKeyName>skyline:sdk:dataminertoken</CatalogPublishKeyName>
<CatalogDefaultDownloadKeyName>skyline:sdk:dataminertoken</CatalogDefaultDownloadKeyName>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "dynamics-activities",
"private": true,
"version": "1.5.2",
"version": "1.5.3",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
110 changes: 110 additions & 0 deletions src/api/graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,116 @@ function normaliseMessage(message) {
}
}

// ─── People / mailbox search ─────────────────────────────────────────────────
/**
* Search org people by name or email fragment using the Microsoft People API.
* Returns contacts ranked by interaction frequency — shared mailboxes the user
* regularly emails will surface here.
* Requires People.Read scope (delegated, no admin consent needed).
*/
export async function searchPeopleMailboxes(msalInstance, query) {
if (!query || query.trim().length < 2) return []
try {
const token = await getGraphToken(msalInstance)
const q = encodeURIComponent(`"${query.trim()}"`)
const url = `${GRAPH}/me/people?$search=${q}&$select=displayName,scoredEmailAddresses&$top=10`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
})
if (!res.ok) return []
Comment on lines +186 to +190
const data = await res.json()
return (data.value ?? [])
.map((p) => ({
email: p.scoredEmailAddresses?.[0]?.address ?? '',
label: p.displayName || '',
}))
.filter((p) => p.email)
} catch {
return []
}
}

const MAILBOX_CACHE_KEY = 'accessibleSharedMailboxes'
const MAILBOX_CACHE_TTL = 15 * 60 * 1000 // 15 minutes

/**
* Filter a list of mailbox candidates to only those the signed-in user can
* actually access (Exchange FullAccess). Uses Graph Batch API to probe up to
* 20 mailboxes per HTTP request. Results are cached in sessionStorage (15 min).
*
* Returns the subset of candidates that returned HTTP 200 on inbox probe.
* Requires Mail.Read or Mail.Read.Shared scope (already present).
*/
export async function filterAccessibleMailboxes(msalInstance, candidates) {
if (!candidates.length) return []

// Include a fingerprint of the candidate list so changing the list busts the cache
const candidateKey = candidates.map((c) => c.email.toLowerCase()).sort().join(',')

// Return cached results if still fresh and for the same candidate set
try {
const cached = JSON.parse(sessionStorage.getItem(MAILBOX_CACHE_KEY) || 'null')
if (cached && cached.candidateKey === candidateKey && Date.now() - cached.ts < MAILBOX_CACHE_TTL) {
const accessibleEmails = new Set(cached.emails)
return candidates.filter((c) => accessibleEmails.has(c.email.toLowerCase()))
}
} catch { /* ignore corrupt cache */ }

try {
const token = await getGraphToken(msalInstance)
const accessibleEmails = new Set()

// Process in chunks of 20 (Graph Batch API limit).
// Probe the messages endpoint with $top=1 — same path used by the inbox loader,
// so 200/403/404 here matches what the real inbox load will get.
for (let i = 0; i < candidates.length; i += 20) {
const chunk = candidates.slice(i, i + 20)
const requests = chunk.map((mb, idx) => ({
id: `${idx}`,
method: 'GET',
url: `/users/${encodeURIComponent(mb.email)}/mailFolders/inbox/messages?$top=1&$select=id`,
}))
const res = await fetch(`${GRAPH}/$batch`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ requests }),
})
if (!res.ok) continue
const { responses } = await res.json()
responses.forEach((r) => {
if (r.status === 200) accessibleEmails.add(chunk[parseInt(r.id)].email.toLowerCase())
})
}

sessionStorage.setItem(MAILBOX_CACHE_KEY, JSON.stringify({
emails: [...accessibleEmails],
candidateKey,
ts: Date.now(),
}))

return candidates.filter((c) => accessibleEmails.has(c.email.toLowerCase()))
} catch {
// On any error return empty — safer than showing inaccessible mailboxes
return []
}
}

/**
* Remove a mailbox address from the sessionStorage access cache.
* Call this when a previously-trusted mailbox returns an access error at runtime.
*/
export function invalidateMailboxCache(email) {
try {
const cached = JSON.parse(sessionStorage.getItem(MAILBOX_CACHE_KEY) || 'null')
if (!cached) return
const updated = cached.emails.filter((e) => e !== email.toLowerCase())
sessionStorage.setItem(MAILBOX_CACHE_KEY, JSON.stringify({ emails: updated, ts: cached.ts }))
Comment on lines +277 to +280
} catch { /* ignore */ }
}

// ─── Calendar events ──────────────────────────────────────────────────────────
// Returns non-all-day events: 60 days past + 30 days future, sorted newest first
export async function getRecentCalendarEvents(msalInstance) {
Expand Down
3 changes: 2 additions & 1 deletion src/authConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const msalConfig = {

// Scopes for initial login (Graph)
export const loginRequest = {
scopes: ['User.Read', 'Calendars.Read', 'Mail.Read', 'Mail.Read.Shared'],
scopes: ['User.Read', 'Calendars.Read', 'Mail.Read', 'Mail.Read.Shared', 'People.Read'],
}

// Scopes for Dataverse token
Expand All @@ -34,5 +34,6 @@ export const graphRequest = {
'https://graph.microsoft.com/Calendars.Read',
'https://graph.microsoft.com/Mail.Read',
'https://graph.microsoft.com/Mail.Read.Shared',
'https://graph.microsoft.com/People.Read',
],
}
22 changes: 20 additions & 2 deletions src/components/AutocompletePicker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { useState, useEffect, useRef } from 'react'
* clearOnPick — if true, clears input after selection (for multi-add flows)
* minChars — minimum chars before searching (default 2)
* debounce — ms delay (default 300)
* initialSuggestions — items shown on focus when query is empty / below minChars
* onQueryChange(q) — called on every input change with the raw query string
* onBlur() — called after the blur+close delay (use for free-text commit)
*/
export default function AutocompletePicker({
searchFn,
Expand All @@ -28,6 +31,9 @@ export default function AutocompletePicker({
autoSelectSingle = false,
minChars = 2,
debounce = 300,
initialSuggestions = [],
onQueryChange,
onBlur,
}) {
const [query, setQuery] = useState(value ? getLabel(value) : '')
const [results, setResults] = useState([])
Expand All @@ -47,6 +53,7 @@ export default function AutocompletePicker({
function handleInput(e) {
const q = e.target.value
setQuery(q)
if (onQueryChange) onQueryChange(q)
if (!q) {
onChange(null)
setResults([])
Expand Down Expand Up @@ -127,8 +134,19 @@ export default function AutocompletePicker({
placeholder={placeholder}
value={query}
onChange={handleInput}
onBlur={() => setTimeout(() => { setOpen(false); setActiveIndex(-1) }, 150)}
onFocus={() => results.length > 0 && setOpen(true)}
onBlur={() => setTimeout(() => {
setOpen(false)
setActiveIndex(-1)
if (onBlur) onBlur()
}, 150)}
onFocus={() => {
if (results.length > 0) {
setOpen(true)
} else if (query.trim().length < minChars && initialSuggestions.length > 0) {
setResults(initialSuggestions)
setOpen(true)
}
}}
onKeyDown={handleKeyDown}
autoComplete="off"
/>
Expand Down
88 changes: 77 additions & 11 deletions src/components/InboxTab.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMsal } from '@azure/msal-react'
import { getConversationMessages, getRecentInboxMessages } from '../api/graph'
import { getConversationMessages, getRecentInboxMessages, searchPeopleMailboxes, filterAccessibleMailboxes, invalidateMailboxCache } from '../api/graph'
import {
checkSyncedMessageIds,
createContact,
Expand Down Expand Up @@ -781,7 +781,7 @@ export default function InboxTab({ compact = false, onImported }) {
const [externalOnly, setExternalOnly] = useState(false)
const [syncedSet, setSyncedSet] = useState(new Set())
const [mailbox, setMailbox] = useState('')
const [mailboxDraft, setMailboxDraft] = useState('')
const mailboxQueryRef = useRef('')
const [selectedThreadKey, setSelectedThreadKey] = useState(null)
const [addingThread, setAddingThread] = useState(null)
const sentinelRef = useRef(null)
Expand All @@ -808,7 +808,10 @@ export default function InboxTab({ compact = false, onImported }) {
const ids = items.map((m) => m.internetMessageId).filter(Boolean)
checkSyncedMessageIds(instance, ids).then(mergeChecked).catch(() => {})
})
.catch((e) => setError(e.message))
.catch((e) => {
if (mailbox) invalidateMailboxCache(mailbox)
setError(e.message)
})
Comment on lines +811 to +814
.finally(() => setLoading(false))
}, [instance, mailbox])

Expand Down Expand Up @@ -862,8 +865,59 @@ export default function InboxTab({ compact = false, onImported }) {
}
}, [filteredThreads, allThreads, selectedThreadKey])

const initialMailboxSuggestions = useMemo(() => {
const fromEnv = (import.meta.env.VITE_SHARED_MAILBOXES || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((e) => ({ email: e, label: e }))
const envEmails = new Set(fromEnv.map((m) => m.email))
const recents = JSON.parse(localStorage.getItem('inbox_recent_mailboxes') || '[]')
.filter((e) => !envEmails.has(e))
.map((e) => ({ email: e, label: e }))
return [...fromEnv, ...recents].slice(0, 8)
Comment on lines +874 to +878
}, [])

const [accessibleSuggestions, setAccessibleSuggestions] = useState([])

useEffect(() => {
if (!initialMailboxSuggestions.length) return
filterAccessibleMailboxes(instance, initialMailboxSuggestions)
.then(setAccessibleSuggestions)
.catch(() => {/* keep empty on error */})
}, [instance, initialMailboxSuggestions])

// Search function: People API results filtered to only accessible mailboxes.
// Accessible mailboxes that match the query locally are always included first,
// so the user gets immediate results even if People API is slow.
async function searchMailboxes(q) {
const lq = q.toLowerCase()
const localMatches = accessibleSuggestions.filter(
(m) => m.email.toLowerCase().includes(lq) || m.label.toLowerCase().includes(lq),
)
try {
const people = await searchPeopleMailboxes(instance, q)
const accessibleEmails = new Set(accessibleSuggestions.map((m) => m.email.toLowerCase()))
const remoteMatches = people.filter((p) => accessibleEmails.has(p.email.toLowerCase()))
// Merge: local first, then remote results with richer labels (deduped by email)
const seen = new Set(localMatches.map((m) => m.email.toLowerCase()))
return [...localMatches, ...remoteMatches.filter((r) => !seen.has(r.email.toLowerCase()))]
} catch {
return localMatches
}
}

function saveMailboxToRecents(address) {
if (!address) return
const recents = JSON.parse(localStorage.getItem('inbox_recent_mailboxes') || '[]')
const updated = [address, ...recents.filter((a) => a !== address)].slice(0, 8)
localStorage.setItem('inbox_recent_mailboxes', JSON.stringify(updated))
Comment on lines +911 to +914
}

function commitMailbox() {
setMailbox(mailboxDraft.trim())
const address = mailboxQueryRef.current.trim()
if (address) saveMailboxToRecents(address)
setMailbox(address)
}

return (
Expand All @@ -873,19 +927,31 @@ export default function InboxTab({ compact = false, onImported }) {
<div className="filter-field">
<label className="filter-label">Mailbox</label>
<div className="mailbox-field">
<input
className="input"
value={mailboxDraft}
onChange={(e) => setMailboxDraft(e.target.value)}
<AutocompletePicker
searchFn={searchMailboxes}
getKey={(item) => item.email}
getLabel={(item) => item.label && item.label !== item.email ? item.label : item.email}
getSublabel={(item) => item.label && item.label !== item.email ? item.email : null}
value={mailbox ? { email: mailbox, label: mailbox } : null}
onChange={(item) => {
const address = item?.email ?? ''
if (address) saveMailboxToRecents(address)
mailboxQueryRef.current = address
setMailbox(address)
}}
onEnter={commitMailbox}
onBlur={commitMailbox}
onKeyDown={(e) => e.key === 'Enter' && commitMailbox()}
placeholder="My mailbox"
onQueryChange={(q) => { mailboxQueryRef.current = q }}
initialSuggestions={accessibleSuggestions}
placeholder="Type or select a mailbox…"
clearOnPick={false}
minChars={2}
/>
{mailbox && (
<button
type="button"
className="mailbox-clear"
onClick={() => { setMailboxDraft(''); setMailbox('') }}
onClick={() => { mailboxQueryRef.current = ''; setMailbox('') }}
aria-label="Clear mailbox"
>×</button>
)}
Expand Down