Initial commit: OL Rescue Outlook add-in (New Outlook + Graph)
Made-with: Cursor
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# Azure AD app registration (single-page application). See README.
|
||||
VITE_AZURE_CLIENT_ID=
|
||||
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Production build
|
||||
dist/
|
||||
build/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment — never commit secrets; template is committed
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Test / coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Caches
|
||||
.cache/
|
||||
.vite/
|
||||
.parcel-cache/
|
||||
.eslintcache
|
||||
*.cache
|
||||
|
||||
# IDE / editors (keep team-shared .vscode/*.json in repo if you add them with !)
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Optional local HTTPS / signing material (never commit real certs)
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# Archives from npm pack / releases
|
||||
*.tgz
|
||||
62
README.md
Normal file
62
README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# OL Rescue
|
||||
|
||||
Outlook **Office Web Add-in** that recreates the old “OL Helper” workflow for **New Outlook** (and Outlook on the web): you define a **Tracking ID** and an **Inbox subfolder name**, then the add-in uses **Microsoft Graph** to create the folder (if needed) and move messages that contain `TrackingID#…` in the subject or preview.
|
||||
|
||||
Classic COM/VSTO add-ins do not run in New Outlook. The supported path is **Office.js + Graph**, which this project uses.
|
||||
|
||||
## What you need
|
||||
|
||||
- **Node.js 20+** and npm (for local HTTPS dev and builds).
|
||||
- **Microsoft 365 / Outlook** with add-ins allowed by your org.
|
||||
- An **Azure AD app registration** (see below) with Graph delegated permissions: **Mail.ReadWrite**, **User.Read**.
|
||||
|
||||
## Azure AD app (summary)
|
||||
|
||||
1. In [Azure Portal](https://portal.azure.com) → **Microsoft Entra ID** → **App registrations** → **New registration**.
|
||||
2. Name it (e.g. “OL Rescue”), supported account types per your tenant, **Redirect URI**: platform **Single-page application (SPA)**, URI `https://localhost:3000` (and later your production URL).
|
||||
3. **API permissions** → **Microsoft Graph** → **Delegated**: add **Mail.ReadWrite**, **User.Read** → **Grant admin consent** if required.
|
||||
4. **Overview** → copy **Application (client) ID**.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd "/path/to/OL Rescue!"
|
||||
cp .env.example .env
|
||||
# Set VITE_AZURE_CLIENT_ID to your client ID
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite serves **HTTPS** on port **3000** (self-signed cert; your browser will warn once).
|
||||
|
||||
## Sideload the manifest in Outlook (Windows New Outlook)
|
||||
|
||||
1. Ensure `manifest.xml` still points to `https://localhost:3000/index.html` (default for dev).
|
||||
2. In Outlook: **File** → **Manage Add-ins** / **Get Add-ins** → **My add-ins** → **Add a custom add-in** → **Add from file…** → choose `manifest.xml`.
|
||||
|
||||
If your org blocks custom add-ins, an admin may need to deploy via **Centralized Deployment** or allow sideloading.
|
||||
|
||||
## Use the add-in
|
||||
|
||||
1. Open a ticket email and open the **OL Rescue** task pane.
|
||||
2. **Sign in with Microsoft** (Graph consent).
|
||||
3. **Fill from open message** to parse `TrackingID#…` and suggest a folder name, or type **Tracking ID** and **Inbox folder name** manually.
|
||||
4. **Save case**, then **Create folder & move mail** on that case to create `Inbox\<folder name>` and move matching messages.
|
||||
|
||||
Saved cases are stored in **browser localStorage** for this add-in origin.
|
||||
|
||||
## Production
|
||||
|
||||
1. Host the `dist/` folder (or your static host) over **HTTPS**.
|
||||
2. Update **Application ID URI / redirect URIs** in Azure for your real origin.
|
||||
3. Edit **manifest.xml** `SourceLocation`, `IconUrl`, and `SupportUrl` to your HTTPS URLs, then rebuild (`npm run build`) and redeploy.
|
||||
|
||||
## Limits vs old OL Helper
|
||||
|
||||
- **Not automatic on every new message** unless you add something else (e.g. Outlook rules calling a service, or Power Automate). This tool is **on-demand** from the task pane.
|
||||
- **Graph search** uses `$filter` on subject/body preview; very unusual formats might need a small follow-up change.
|
||||
- **Shared mailboxes** need extra Graph permissions and possibly UX changes; this MVP targets your primary mailbox (`/me/…`).
|
||||
|
||||
## License
|
||||
|
||||
Use and modify for your work as needed.
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OL Rescue</title>
|
||||
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
manifest.xml
Normal file
39
manifest.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:type="MailApp">
|
||||
<Id>0b5e2d6a-8a1c-4f2e-9e0b-4a1c2d3e4f5a</Id>
|
||||
<Version>1.0.0.0</Version>
|
||||
<ProviderName>OL Rescue</ProviderName>
|
||||
<DefaultLocale>en-US</DefaultLocale>
|
||||
<DisplayName DefaultValue="OL Rescue"/>
|
||||
<Description DefaultValue="File mail by TrackingID into Inbox subfolders for New Outlook (Microsoft Graph)."/>
|
||||
<IconUrl DefaultValue="https://localhost:3000/icon-32.png"/>
|
||||
<HighResolutionIconUrl DefaultValue="https://localhost:3000/icon-128.png"/>
|
||||
<SupportUrl DefaultValue="https://localhost:3000"/>
|
||||
<AppDomains>
|
||||
<AppDomain>https://localhost:3000</AppDomain>
|
||||
<AppDomain>https://login.microsoftonline.com</AppDomain>
|
||||
</AppDomains>
|
||||
<Hosts>
|
||||
<Host Name="Mailbox"/>
|
||||
</Hosts>
|
||||
<Requirements>
|
||||
<Sets>
|
||||
<Set Name="Mailbox" MinVersion="1.8"/>
|
||||
</Sets>
|
||||
</Requirements>
|
||||
<FormSettings>
|
||||
<Form xsi:type="ItemRead">
|
||||
<DesktopSettings>
|
||||
<SourceLocation DefaultValue="https://localhost:3000/index.html"/>
|
||||
<RequestedHeight>450</RequestedHeight>
|
||||
</DesktopSettings>
|
||||
</Form>
|
||||
</FormSettings>
|
||||
<Permissions>ReadWriteItem</Permissions>
|
||||
<Rule xsi:type="Rule" FormType="Read">
|
||||
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
|
||||
</Rule>
|
||||
<DisableEntityHighlighting>false</DisableEntityHighlighting>
|
||||
</OfficeApp>
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "ol-rescue",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Outlook add-in: file mail by internal TrackingID into named folders (New Outlook / web)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/office-js": "^1.0.534",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"@vitejs/plugin-basic-ssl": "^1.2.0"
|
||||
}
|
||||
}
|
||||
BIN
public/icon-128.png
Normal file
BIN
public/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 306 B |
BIN
public/icon-32.png
Normal file
BIN
public/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 B |
329
src/app.ts
Normal file
329
src/app.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import {
|
||||
createMsal,
|
||||
ensureActiveAccount,
|
||||
getActiveAccount,
|
||||
getGraphAccessToken,
|
||||
initializeAuth,
|
||||
signIn,
|
||||
signOut,
|
||||
} from "./auth";
|
||||
import {
|
||||
findMessagesWithTrackingId,
|
||||
findOrCreateInboxChildFolder,
|
||||
moveMessageToFolder,
|
||||
} from "./graph";
|
||||
import { extractTrackingId, suggestFolderDisplayName } from "./parse";
|
||||
import { type CaseEntry, loadCases, saveCases } from "./storage";
|
||||
import "./styles.css";
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
props: Record<string, string | undefined> = {},
|
||||
children: (Node | string)[] = [],
|
||||
): HTMLElement {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (v === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (k === "className") {
|
||||
node.className = v;
|
||||
} else if (k.startsWith("on") && typeof v === "string") {
|
||||
// not used
|
||||
} else {
|
||||
node.setAttribute(k, v);
|
||||
}
|
||||
}
|
||||
for (const c of children) {
|
||||
node.append(typeof c === "string" ? document.createTextNode(c) : c);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function getClientId(): string | undefined {
|
||||
const id = import.meta.env.VITE_AZURE_CLIENT_ID?.trim();
|
||||
return id || undefined;
|
||||
}
|
||||
|
||||
export async function initApp(): Promise<void> {
|
||||
const root = document.getElementById("app");
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = getClientId();
|
||||
if (!clientId) {
|
||||
root.replaceChildren(
|
||||
el("div", { className: "panel" }, [
|
||||
el("h1", {}, ["OL Rescue"]),
|
||||
el("p", {}, [
|
||||
"Add your Azure AD app client ID to ",
|
||||
el("code", {}, [".env"]),
|
||||
" as ",
|
||||
el("code", {}, ["VITE_AZURE_CLIENT_ID"]),
|
||||
", then restart ",
|
||||
el("code", {}, ["npm run dev"]),
|
||||
".",
|
||||
]),
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const msal = createMsal(clientId);
|
||||
await initializeAuth(msal);
|
||||
ensureActiveAccount(msal);
|
||||
|
||||
let cases = loadCases();
|
||||
|
||||
const statusEl = el("div", { className: "status", role: "status" });
|
||||
const logEl = el("pre", { className: "log" });
|
||||
|
||||
function setStatus(msg: string): void {
|
||||
statusEl.textContent = msg;
|
||||
}
|
||||
|
||||
function appendLog(line: string): void {
|
||||
logEl.textContent = `${logEl.textContent ?? ""}${line}\n`;
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
const trackingInput = el("input", {
|
||||
type: "text",
|
||||
className: "input",
|
||||
placeholder: "e.g. 2603260030007249",
|
||||
"aria-label": "Tracking ID",
|
||||
}) as HTMLInputElement;
|
||||
|
||||
const folderInput = el("input", {
|
||||
type: "text",
|
||||
className: "input",
|
||||
placeholder: 'e.g. 2603260030007249 Crazy Netflix Case',
|
||||
"aria-label": "Folder name under Inbox",
|
||||
}) as HTMLInputElement;
|
||||
|
||||
const caseList = el("div", { className: "case-list" });
|
||||
|
||||
function persist(): void {
|
||||
saveCases(cases);
|
||||
}
|
||||
|
||||
function renderCaseList(): void {
|
||||
caseList.replaceChildren();
|
||||
if (cases.length === 0) {
|
||||
caseList.append(el("p", { className: "muted" }, ["No saved cases yet."]));
|
||||
return;
|
||||
}
|
||||
for (const c of cases) {
|
||||
const row = el("div", { className: "case-row" });
|
||||
row.append(
|
||||
el("div", { className: "case-meta" }, [
|
||||
el("strong", {}, [c.folderDisplayName]),
|
||||
el("span", { className: "muted" }, [` · TrackingID#${c.trackingId}`]),
|
||||
]),
|
||||
);
|
||||
const actions = el("div", { className: "case-actions" });
|
||||
const syncBtn = el("button", { type: "button", className: "btn btn-small" }, [
|
||||
"Create folder & move mail",
|
||||
]);
|
||||
syncBtn.addEventListener("click", () => void syncCase(c.id));
|
||||
const removeBtn = el("button", { type: "button", className: "btn btn-small btn-ghost" }, [
|
||||
"Remove",
|
||||
]);
|
||||
removeBtn.addEventListener("click", () => {
|
||||
cases = cases.filter((x) => x.id !== c.id);
|
||||
persist();
|
||||
renderCaseList();
|
||||
});
|
||||
actions.append(syncBtn, removeBtn);
|
||||
row.append(actions);
|
||||
caseList.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCase(caseId: string): Promise<void> {
|
||||
const entry = cases.find((c) => c.id === caseId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
setStatus("Working…");
|
||||
appendLog(`— ${entry.folderDisplayName}`);
|
||||
try {
|
||||
const token = await getGraphAccessToken(msal);
|
||||
appendLog("Ensuring folder exists under Inbox…");
|
||||
const folder = await findOrCreateInboxChildFolder(token, entry.folderDisplayName);
|
||||
entry.graphFolderId = folder.id;
|
||||
persist();
|
||||
appendLog(`Finding messages with TrackingID#${entry.trackingId}…`);
|
||||
const messages = await findMessagesWithTrackingId(token, entry.trackingId);
|
||||
appendLog(`Found ${messages.length} message(s).`);
|
||||
let moved = 0;
|
||||
for (const m of messages) {
|
||||
try {
|
||||
await moveMessageToFolder(token, m.id, folder.id);
|
||||
moved += 1;
|
||||
appendLog(`Moved: ${m.subject ?? m.id}`);
|
||||
} catch (e) {
|
||||
appendLog(`Skip/fail: ${m.subject ?? m.id} — ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
appendLog(`Done. Moved ${moved} message(s).`);
|
||||
setStatus(`Last run: moved ${moved} of ${messages.length}.`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
appendLog(`Error: ${msg}`);
|
||||
setStatus("Failed — see log.");
|
||||
}
|
||||
}
|
||||
|
||||
async function fillFromOpenMessage(): Promise<void> {
|
||||
const item = Office.context.mailbox.item;
|
||||
if (!item) {
|
||||
setStatus("Open a message first.");
|
||||
return;
|
||||
}
|
||||
setStatus("Reading open message…");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
item.subject.getAsync((r) => {
|
||||
if (r.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(new Error(r.error?.message ?? "Could not read subject."));
|
||||
return;
|
||||
}
|
||||
const subject = r.value ?? "";
|
||||
const id = extractTrackingId(subject);
|
||||
if (id) {
|
||||
trackingInput.value = id;
|
||||
folderInput.value = suggestFolderDisplayName(subject, id);
|
||||
setStatus("Filled from subject.");
|
||||
} else {
|
||||
item.body.getAsync(Office.CoercionType.Text, (br) => {
|
||||
if (br.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(new Error(br.error?.message ?? "Could not read body."));
|
||||
return;
|
||||
}
|
||||
const bodyText = br.value ?? "";
|
||||
const id2 = extractTrackingId(bodyText);
|
||||
if (id2) {
|
||||
trackingInput.value = id2;
|
||||
folderInput.value = suggestFolderDisplayName(subject || "Case", id2);
|
||||
setStatus("Filled from body text.");
|
||||
} else {
|
||||
setStatus("No TrackingID# found in subject or body.");
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}).catch((e) => {
|
||||
setStatus(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
}
|
||||
|
||||
function addOrUpdateCase(): void {
|
||||
const trackingId = trackingInput.value.trim();
|
||||
const folderDisplayName = folderInput.value.trim();
|
||||
if (!/^\d+$/.test(trackingId)) {
|
||||
setStatus("Tracking ID should be digits only (from TrackingID#…).");
|
||||
return;
|
||||
}
|
||||
if (!folderDisplayName) {
|
||||
setStatus("Enter a folder name.");
|
||||
return;
|
||||
}
|
||||
const existing = cases.find((c) => c.trackingId === trackingId);
|
||||
if (existing) {
|
||||
existing.folderDisplayName = folderDisplayName;
|
||||
} else {
|
||||
cases.push({
|
||||
id: crypto.randomUUID(),
|
||||
trackingId,
|
||||
folderDisplayName,
|
||||
});
|
||||
}
|
||||
persist();
|
||||
renderCaseList();
|
||||
setStatus("Case saved.");
|
||||
}
|
||||
|
||||
const authBar = el("div", { className: "auth-bar" });
|
||||
const userLabel = el("span", { className: "muted" });
|
||||
|
||||
function renderAuth(): void {
|
||||
authBar.replaceChildren();
|
||||
const acc = getActiveAccount(msal);
|
||||
if (acc) {
|
||||
userLabel.textContent = acc.username ?? acc.name ?? "Signed in";
|
||||
const outBtn = el("button", { type: "button", className: "btn btn-ghost" }, ["Sign out"]);
|
||||
outBtn.addEventListener("click", () => {
|
||||
signOut(msal);
|
||||
userLabel.textContent = "";
|
||||
renderAuth();
|
||||
setStatus("Signed out.");
|
||||
});
|
||||
authBar.append(userLabel, outBtn);
|
||||
} else {
|
||||
const inBtn = el("button", { type: "button", className: "btn" }, ["Sign in with Microsoft"]);
|
||||
inBtn.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await signIn(msal);
|
||||
renderAuth();
|
||||
setStatus("Signed in.");
|
||||
} catch (e) {
|
||||
setStatus(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})();
|
||||
});
|
||||
authBar.append(inBtn);
|
||||
}
|
||||
}
|
||||
|
||||
renderAuth();
|
||||
renderCaseList();
|
||||
|
||||
const saveCaseBtn = el("button", { type: "button", className: "btn" }, ["Save case"]);
|
||||
const fillFromBtn = el("button", { type: "button", className: "btn btn-secondary" }, [
|
||||
"Fill from open message",
|
||||
]);
|
||||
saveCaseBtn.addEventListener("click", () => addOrUpdateCase());
|
||||
fillFromBtn.addEventListener("click", () => void fillFromOpenMessage());
|
||||
|
||||
root.replaceChildren(
|
||||
el("div", { className: "wrap" }, [
|
||||
el("header", { className: "header" }, [
|
||||
el("h1", {}, ["OL Rescue"]),
|
||||
el("p", { className: "lede" }, [
|
||||
"Save a case (Tracking ID + folder name), then move matching mail into an Inbox subfolder. Works in ",
|
||||
el("strong", {}, ["New Outlook"]),
|
||||
" via Microsoft Graph.",
|
||||
]),
|
||||
authBar,
|
||||
]),
|
||||
el("section", { className: "panel" }, [
|
||||
el("h2", {}, ["Case"]),
|
||||
el("label", { className: "label" }, ["Tracking ID"]),
|
||||
trackingInput,
|
||||
el("label", { className: "label" }, ["Inbox folder name"]),
|
||||
folderInput,
|
||||
el("div", { className: "btn-row" }, [saveCaseBtn, fillFromBtn]),
|
||||
]),
|
||||
el("section", { className: "panel" }, [
|
||||
el("h2", {}, ["Saved cases"]),
|
||||
caseList,
|
||||
]),
|
||||
el("section", { className: "panel" }, [
|
||||
el("h2", {}, ["Activity"]),
|
||||
statusEl,
|
||||
logEl,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
if (getActiveAccount(msal)) {
|
||||
setStatus("Ready.");
|
||||
} else {
|
||||
setStatus("Sign in to use Microsoft Graph.");
|
||||
}
|
||||
}
|
||||
80
src/auth.ts
Normal file
80
src/auth.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
InteractionRequiredAuthError,
|
||||
type AccountInfo,
|
||||
type IPublicClientApplication,
|
||||
PublicClientApplication,
|
||||
} from "@azure/msal-browser";
|
||||
|
||||
const GRAPH_SCOPES = [
|
||||
"https://graph.microsoft.com/Mail.ReadWrite",
|
||||
"https://graph.microsoft.com/User.Read",
|
||||
];
|
||||
|
||||
export function createMsal(clientId: string): PublicClientApplication {
|
||||
return new PublicClientApplication({
|
||||
auth: {
|
||||
clientId,
|
||||
authority: "https://login.microsoftonline.com/common",
|
||||
redirectUri: window.location.origin,
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: "localStorage",
|
||||
storeAuthStateInCookie: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function initializeAuth(msal: IPublicClientApplication): Promise<void> {
|
||||
await msal.initialize();
|
||||
await msal.handleRedirectPromise();
|
||||
}
|
||||
|
||||
export async function signIn(msal: IPublicClientApplication): Promise<AccountInfo> {
|
||||
await initializeAuth(msal);
|
||||
const result = await msal.loginPopup({ scopes: GRAPH_SCOPES });
|
||||
if (!result.account) {
|
||||
throw new Error("Sign-in did not return an account.");
|
||||
}
|
||||
msal.setActiveAccount(result.account);
|
||||
return result.account;
|
||||
}
|
||||
|
||||
/** Call after initializeAuth when a single cached account should be used. */
|
||||
export function ensureActiveAccount(msal: IPublicClientApplication): void {
|
||||
if (msal.getActiveAccount()) {
|
||||
return;
|
||||
}
|
||||
const accounts = msal.getAllAccounts();
|
||||
if (accounts.length === 1) {
|
||||
msal.setActiveAccount(accounts[0]);
|
||||
}
|
||||
}
|
||||
|
||||
export function signOut(msal: IPublicClientApplication): void {
|
||||
const account = msal.getActiveAccount() ?? msal.getAllAccounts()[0];
|
||||
if (account) {
|
||||
void msal.logoutPopup({ account });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGraphAccessToken(msal: IPublicClientApplication): Promise<string> {
|
||||
await initializeAuth(msal);
|
||||
const account = msal.getActiveAccount() ?? msal.getAllAccounts()[0];
|
||||
if (!account) {
|
||||
throw new Error("Not signed in.");
|
||||
}
|
||||
try {
|
||||
const result = await msal.acquireTokenSilent({ scopes: GRAPH_SCOPES, account });
|
||||
return result.accessToken;
|
||||
} catch (e) {
|
||||
if (e instanceof InteractionRequiredAuthError) {
|
||||
const result = await msal.acquireTokenPopup({ scopes: GRAPH_SCOPES, account });
|
||||
return result.accessToken;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveAccount(msal: IPublicClientApplication): AccountInfo | null {
|
||||
return msal.getActiveAccount() ?? msal.getAllAccounts()[0] ?? null;
|
||||
}
|
||||
127
src/graph.ts
Normal file
127
src/graph.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
const GRAPH = "https://graph.microsoft.com/v1.0";
|
||||
|
||||
function escapeODataString(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
export interface GraphMessage {
|
||||
id: string;
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
export interface GraphMailFolder {
|
||||
id: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
async function graphJson<T>(token: string, pathOrUrl: string, init?: RequestInit): Promise<T> {
|
||||
const url = pathOrUrl.startsWith("https://") ? pathOrUrl : `${GRAPH}${pathOrUrl}`;
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Graph ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listInboxChildFolders(token: string): Promise<GraphMailFolder[]> {
|
||||
const out: GraphMailFolder[] = [];
|
||||
let next: string | null =
|
||||
"/me/mailFolders/inbox/childFolders?$select=id,displayName&$top=999";
|
||||
|
||||
while (next) {
|
||||
const page = await graphJson<{ value: GraphMailFolder[]; "@odata.nextLink"?: string }>(
|
||||
token,
|
||||
next,
|
||||
);
|
||||
out.push(...page.value);
|
||||
next = page["@odata.nextLink"] ?? null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function findChildFolderByName(
|
||||
token: string,
|
||||
displayName: string,
|
||||
): Promise<GraphMailFolder | undefined> {
|
||||
const folders = await listInboxChildFolders(token);
|
||||
return folders.find((f) => f.displayName === displayName);
|
||||
}
|
||||
|
||||
export async function createInboxChildFolder(
|
||||
token: string,
|
||||
displayName: string,
|
||||
): Promise<GraphMailFolder> {
|
||||
return graphJson<GraphMailFolder>(token, "/me/mailFolders/inbox/childFolders", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ displayName }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function findOrCreateInboxChildFolder(
|
||||
token: string,
|
||||
displayName: string,
|
||||
): Promise<GraphMailFolder> {
|
||||
const existing = await findChildFolderByName(token, displayName);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return createInboxChildFolder(token, displayName);
|
||||
}
|
||||
|
||||
async function listMessagesMatchingFilter(
|
||||
token: string,
|
||||
filter: string,
|
||||
): Promise<GraphMessage[]> {
|
||||
const out: GraphMessage[] = [];
|
||||
let next: string | null =
|
||||
`/me/messages?$filter=${encodeURIComponent(filter)}&$select=id,subject&$top=999`;
|
||||
|
||||
while (next) {
|
||||
const page = await graphJson<{ value: GraphMessage[]; "@odata.nextLink"?: string }>(
|
||||
token,
|
||||
next,
|
||||
);
|
||||
out.push(...page.value);
|
||||
next = page["@odata.nextLink"] ?? null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds messages whose subject or preview contains "TrackingID#<id>".
|
||||
* Falls back to subject-only filter if the tenant rejects bodyPreview in $filter.
|
||||
*/
|
||||
export async function findMessagesWithTrackingId(
|
||||
token: string,
|
||||
trackingId: string,
|
||||
): Promise<GraphMessage[]> {
|
||||
const needle = `TrackingID#${trackingId}`;
|
||||
const q = escapeODataString(needle);
|
||||
const combined = `(contains(subject,'${q}') or contains(bodyPreview,'${q}'))`;
|
||||
const subjectOnly = `contains(subject,'${q}')`;
|
||||
try {
|
||||
return await listMessagesMatchingFilter(token, combined);
|
||||
} catch {
|
||||
return listMessagesMatchingFilter(token, subjectOnly);
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveMessageToFolder(
|
||||
token: string,
|
||||
messageId: string,
|
||||
destinationFolderId: string,
|
||||
): Promise<void> {
|
||||
await graphJson<unknown>(token, `/me/messages/${encodeURIComponent(messageId)}/move`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ destinationId: destinationFolderId }),
|
||||
});
|
||||
}
|
||||
14
src/main.ts
Normal file
14
src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/// <reference types="office-js" />
|
||||
import { initApp } from "./app";
|
||||
|
||||
Office.onReady((info) => {
|
||||
const root = document.getElementById("app");
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
if (info.host === Office.HostType.Outlook) {
|
||||
void initApp();
|
||||
return;
|
||||
}
|
||||
root.textContent = "Open OL Rescue in Outlook (desktop or web) to use this add-in.";
|
||||
});
|
||||
28
src/parse.ts
Normal file
28
src/parse.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/** Matches internal ticket markers like TrackingID#2603260030007249 */
|
||||
const TRACKING_RE = /TrackingID#(\d+)/i;
|
||||
|
||||
export function extractTrackingId(text: string): string | null {
|
||||
const m = TRACKING_RE.exec(text);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a suggested folder name: "<id> <short title>" from a message subject line.
|
||||
*/
|
||||
export function suggestFolderDisplayName(subject: string, trackingId: string): string {
|
||||
let title = subject.trim();
|
||||
const lower = title.toLowerCase();
|
||||
const idx = lower.indexOf("trackingid#");
|
||||
if (idx > 0) {
|
||||
title = title.slice(0, idx).trim();
|
||||
title = title.replace(/\s*-\s*$/u, "").trim();
|
||||
}
|
||||
title = title.replace(/^(re|fw|fwd)\s*:\s*/giu, "").trim();
|
||||
title = title.replace(/^\[external\]\s*/giu, "").trim();
|
||||
title = title.replace(/\s+/gu, " ").trim();
|
||||
if (title.length > 80) {
|
||||
title = `${title.slice(0, 77)}...`;
|
||||
}
|
||||
const folder = `${trackingId} ${title || "Case"}`.trim();
|
||||
return folder.length > 255 ? folder.slice(0, 255) : folder;
|
||||
}
|
||||
35
src/storage.ts
Normal file
35
src/storage.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface CaseEntry {
|
||||
id: string;
|
||||
trackingId: string;
|
||||
folderDisplayName: string;
|
||||
graphFolderId?: string;
|
||||
}
|
||||
|
||||
const KEY = "ol-rescue-cases-v1";
|
||||
|
||||
export function loadCases(): CaseEntry[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter(
|
||||
(x): x is CaseEntry =>
|
||||
typeof x === "object" &&
|
||||
x !== null &&
|
||||
typeof (x as CaseEntry).id === "string" &&
|
||||
typeof (x as CaseEntry).trackingId === "string" &&
|
||||
typeof (x as CaseEntry).folderDisplayName === "string",
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCases(cases: CaseEntry[]): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(cases));
|
||||
}
|
||||
221
src/styles.css
Normal file
221
src/styles.css
Normal file
@@ -0,0 +1,221 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0f1419;
|
||||
--panel: #1a2332;
|
||||
--text: #e8eef7;
|
||||
--muted: #8b9cb3;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--border: #2d3a4f;
|
||||
--radius: 10px;
|
||||
font-family:
|
||||
"Segoe UI Variable",
|
||||
"Segoe UI",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f4f6fa;
|
||||
--panel: #ffffff;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 14px 28px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 0 0 12px;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.auth-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 14px 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 8px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.log {
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.78rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.case-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.case-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.case-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.case-meta strong {
|
||||
font-weight: 650;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.case-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, "Cascadia Code", monospace;
|
||||
font-size: 0.88em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--muted) 18%, transparent);
|
||||
}
|
||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_AZURE_CLIENT_ID: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["vite/client", "office-js"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [basicSsl()],
|
||||
server: {
|
||||
port: 3000,
|
||||
strictPort: true,
|
||||
https: {},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user