├── .github ├── CODEOWNERS ├── workflows │ ├── jest.yml │ ├── dependency-review.yml │ ├── build.yml │ ├── codeql.yml │ └── scorecards.yml └── dependabot.yml ├── .deployment ├── .gitignore ├── favicon.ico ├── index.d.ts ├── src ├── Resources │ ├── loader.gif │ ├── mhaLogo.jpg │ ├── mhaLogo64.jpg │ └── mhaLogo128.jpg ├── Scripts │ ├── Choice.ts │ ├── DeferredError.ts │ ├── Block.ts │ ├── aikey.ts │ ├── mhaVersion.ts │ ├── buildTime.ts │ ├── row │ │ ├── Header.ts │ │ ├── SummaryRow.ts │ │ ├── ReceivedField.ts │ │ ├── ArchivedRow.ts │ │ ├── Match.ts │ │ ├── CreationRow.ts │ │ ├── Row.test.ts │ │ ├── OtherRow.ts │ │ ├── OtherRow.test.ts │ │ ├── Row.ts │ │ ├── Match.test.ts │ │ ├── ArchivedRow.test.ts │ │ ├── SummaryRow.test.ts │ │ ├── CreationRow.test.ts │ │ ├── ReceivedField.test.ts │ │ ├── ReceivedRow.test.ts │ │ ├── ReceivedRow.ts │ │ ├── ForefrontAntispam.ts │ │ └── Antispam.ts │ ├── DateWithNum.ts │ ├── table │ │ ├── Column.ts │ │ ├── SummaryTable.ts │ │ ├── TableSection.ts │ │ ├── DataTable.ts │ │ ├── Column.test.ts │ │ ├── Other.ts │ │ ├── Other.test.ts │ │ ├── TableSection.test.ts │ │ ├── Received.time.test.ts │ │ ├── DataTable.test.ts │ │ └── SummaryTable.test.ts │ ├── Poster.ts │ ├── DateWithNum.test.ts │ ├── Strings.test.ts │ ├── ui │ │ ├── uiToggle.ts │ │ ├── privacy.ts │ │ ├── getHeaders │ │ │ ├── GetHeadersAPI.ts │ │ │ ├── GetHeaders.ts │ │ │ ├── GetHeadersRest.ts │ │ │ └── GetHeadersEWS.ts │ │ ├── classicDesktopFrame.ts │ │ └── mha.ts │ ├── MHADates.test.ts │ ├── jestMatchers │ │ ├── arrayEqual.ts │ │ ├── datesEqual.ts │ │ ├── stacksEqual.ts │ │ └── receivedEqual.ts │ ├── HeaderModel.test.ts │ ├── stacks.ts │ ├── MHADates.ts │ ├── Summary.ts │ ├── mhaStrings.ts │ ├── utils │ │ └── ParentFrameUtils.ts │ ├── Errors.ts │ ├── HeaderModel.ts │ └── Errors.test.ts ├── Pages │ ├── Functions.html │ ├── DesktopPane.html │ ├── Default.html │ ├── DefaultPhone.html │ ├── DefaultTablet.html │ ├── MobilePane.html │ ├── classicDesktopFrame.html │ ├── privacy.html │ ├── mha.html │ ├── uitoggle.html │ └── newMobilePaneIosFrame.html └── Content │ ├── privacy.css │ ├── .csslintrc │ ├── Office.css │ ├── themeColors.css │ ├── uiToggle.css │ └── newMobilePaneIosFrame.css ├── global-setup.js ├── lgtm.yml ├── Dockerfile ├── vwd.webinfo ├── .vscode ├── settings.json ├── extensions.json ├── launch.json └── tasks.json ├── MHA.sln.DotSettings.user ├── tsconfig.json ├── tasks └── clean.js ├── jest.config.ts ├── Web.config ├── LICENSE ├── CONTRIBUTING.md ├── MHA.sln ├── .vsconfig ├── package.json ├── deploy.cmd ├── README.md └── eslint.config.js /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @microsoft/css-outlook -------------------------------------------------------------------------------- /.deployment: -------------------------------------------------------------------------------- 1 | [config] 2 | command = deploy.cmd 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vs 2 | /node_modules 3 | /Pages 4 | /Resources 5 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/MHA/HEAD/favicon.ico -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png"; 2 | declare module "*.jpg"; 3 | declare module "*.css"; 4 | -------------------------------------------------------------------------------- /src/Resources/loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/MHA/HEAD/src/Resources/loader.gif -------------------------------------------------------------------------------- /src/Resources/mhaLogo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/MHA/HEAD/src/Resources/mhaLogo.jpg -------------------------------------------------------------------------------- /src/Resources/mhaLogo64.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/MHA/HEAD/src/Resources/mhaLogo64.jpg -------------------------------------------------------------------------------- /src/Resources/mhaLogo128.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/MHA/HEAD/src/Resources/mhaLogo128.jpg -------------------------------------------------------------------------------- /src/Scripts/Choice.ts: -------------------------------------------------------------------------------- 1 | export class Choice { 2 | label = ""; 3 | url = ""; 4 | checked = false; 5 | } 6 | -------------------------------------------------------------------------------- /src/Scripts/DeferredError.ts: -------------------------------------------------------------------------------- 1 | export class DeferredError { 2 | error: Error = {}; 3 | message = ""; 4 | } 5 | -------------------------------------------------------------------------------- /global-setup.js: -------------------------------------------------------------------------------- 1 | // Use UTC timezone for all tests 2 | export default async () => { 3 | process.env.TZ = "UTC"; 4 | }; 5 | -------------------------------------------------------------------------------- /src/Scripts/Block.ts: -------------------------------------------------------------------------------- 1 | export interface Block { 2 | charset: string; 3 | type: string; 4 | text: string; 5 | } 6 | -------------------------------------------------------------------------------- /lgtm.yml: -------------------------------------------------------------------------------- 1 | path_classifiers: 2 | generated: 3 | - exclude: Pages 4 | queries: 5 | - exclude: js/node/unused-npm-dependency 6 | -------------------------------------------------------------------------------- /src/Scripts/aikey.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error Variable replacement from DefinePlugin 2 | export const aikey = function (): string { return __AIKEY__; }; 3 | -------------------------------------------------------------------------------- /src/Scripts/mhaVersion.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error Variable replacement from DefinePlugin 2 | export const mhaVersion = function () { return __VERSION__; }; 3 | -------------------------------------------------------------------------------- /src/Scripts/buildTime.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error Variable replacement from DefinePlugin 2 | export const buildTime = function ():string { return __BUILDTIME__; }; 3 | -------------------------------------------------------------------------------- /src/Scripts/row/Header.ts: -------------------------------------------------------------------------------- 1 | export class Header { 2 | constructor(header: string, value: string) { 3 | this.header = header; 4 | this.value = value; 5 | } 6 | header: string; 7 | value: string; 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest@sha256:34bb77a39088f2d52fca6b3c965269da281d3b845f5ea06851109547e488bae3 2 | 3 | WORKDIR /app 4 | 5 | RUN git clone https://github.com/microsoft/MHA.git 6 | RUN cd /app/MHA && npm ci && npm run build --if-present 7 | -------------------------------------------------------------------------------- /src/Pages/Functions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Functions 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/Scripts/DateWithNum.ts: -------------------------------------------------------------------------------- 1 | export class DateWithNum { 2 | constructor(dateNum: number, date: string) { 3 | this.dateNum = dateNum; 4 | this.date = date; 5 | } 6 | dateNum: number; 7 | date: string; 8 | public toString = (): string => { return this.date; }; 9 | } 10 | -------------------------------------------------------------------------------- /src/Scripts/table/Column.ts: -------------------------------------------------------------------------------- 1 | export class Column { 2 | constructor(id: string, label: string, columnClass: string) { 3 | this.id = id; 4 | this.label = label; 5 | this.class = columnClass; 6 | } 7 | id: string; 8 | label: string; 9 | class: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/Scripts/row/SummaryRow.ts: -------------------------------------------------------------------------------- 1 | import { Row } from "./Row"; 2 | import { Strings } from "../Strings"; 3 | 4 | export class SummaryRow extends Row { 5 | constructor(header: string, label: string) { 6 | super(header, label, ""); 7 | this.url = Strings.mapHeaderToURL(header, label); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /vwd.webinfo: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /src/Scripts/row/ReceivedField.ts: -------------------------------------------------------------------------------- 1 | export class ReceivedField { 2 | constructor(label: string, value?: string | number | null) { 3 | this.label = label; 4 | this.value = value !== undefined ? value : ""; 5 | } 6 | label: string; 7 | value: string | number | null; 8 | toString(): string { return this.value !== null ? this.value.toString() : "null"; } 9 | } 10 | -------------------------------------------------------------------------------- /src/Scripts/row/ArchivedRow.ts: -------------------------------------------------------------------------------- 1 | import { Strings } from "../Strings"; 2 | import { SummaryRow } from "./SummaryRow"; 3 | 4 | export class ArchivedRow extends SummaryRow { 5 | constructor(header: string, label: string) { 6 | super(header, label); 7 | this.url = Strings.mapHeaderToURL(header, label); 8 | } 9 | override get valueUrl(): string { return Strings.mapValueToURL(this.valueInternal); } 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": false, 3 | "eslint.validate": ["javascript", "javascriptreact", "typescript"], 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll": "explicit" 6 | }, 7 | "jest.jestCommandLine": "npm test --", 8 | "jest.autoRun": { 9 | "watch": false, 10 | "onSave": "test-src-file" 11 | }, 12 | "jest.showCoverageOnLoad": true, 13 | "jest.coverageFormatter": "DefaultFormatter" 14 | } 15 | -------------------------------------------------------------------------------- /src/Scripts/row/Match.ts: -------------------------------------------------------------------------------- 1 | export class Match { 2 | public readonly fieldName: string; 3 | public readonly iToken: number; 4 | 5 | constructor(fieldName: string, iToken: number) { 6 | this.fieldName = fieldName; 7 | this.iToken = iToken; 8 | 9 | // Make properties non-writable 10 | Object.defineProperty(this, "fieldName", { writable: false }); 11 | Object.defineProperty(this, "iToken", { writable: false }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Scripts/table/SummaryTable.ts: -------------------------------------------------------------------------------- 1 | import { TableSection } from "./TableSection"; 2 | import { Row } from "../row/Row"; 3 | 4 | export abstract class SummaryTable extends TableSection { 5 | public abstract get rows(): Row[]; 6 | public abstract readonly tag: string; 7 | public readonly paneClass = "sectionHeader" as const; 8 | 9 | // Default exists implementation for summary tables 10 | public exists(): boolean { 11 | return this.rows.some(row => !!row.value); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Scripts/row/CreationRow.ts: -------------------------------------------------------------------------------- 1 | import { Strings } from "../Strings"; 2 | import { SummaryRow } from "./SummaryRow"; 3 | 4 | export class CreationRow extends SummaryRow { 5 | constructor(header: string, label: string) { 6 | super(header, label); 7 | this.url = Strings.mapHeaderToURL(header, label); 8 | this.postFix = ""; 9 | } 10 | postFix: string; 11 | override get value(): string { return this.valueInternal + this.postFix; } 12 | override set value(value: string) { this.valueInternal = value; } 13 | } 14 | -------------------------------------------------------------------------------- /src/Scripts/row/Row.test.ts: -------------------------------------------------------------------------------- 1 | import { Row } from "./Row"; 2 | 3 | describe("Row", () => { 4 | it("should return the correct id", () => { 5 | const row = new Row("headerValue", "labelValue", "headerNameValue"); 6 | expect(row.id).toBe("headerValue_id"); 7 | }); 8 | 9 | it("should return a different id when header is changed", () => { 10 | const row = new Row("initialHeader", "labelValue", "headerNameValue"); 11 | row.header = "newHeader"; 12 | expect(row.id).toBe("newHeader_id"); 13 | }); 14 | }); -------------------------------------------------------------------------------- /MHA.sln.DotSettings.user: -------------------------------------------------------------------------------- 1 | 2 | True 3 | True 4 | -------------------------------------------------------------------------------- /src/Scripts/row/OtherRow.ts: -------------------------------------------------------------------------------- 1 | import { Strings } from "../Strings"; 2 | import { Row } from "./Row"; 3 | 4 | export class OtherRow extends Row { 5 | constructor(number: number, header: string, value: string) { 6 | super(header, "", header); // headerName is same as header for OtherRow 7 | this.number = number; 8 | this.value = value; 9 | this.url = Strings.mapHeaderToURL(header); 10 | } 11 | 12 | number: number; 13 | override toString() { 14 | return this.header + ": " + this.value; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Scripts/row/OtherRow.test.ts: -------------------------------------------------------------------------------- 1 | import { OtherRow } from "./OtherRow"; 2 | 3 | describe("OtherRow", () => { 4 | describe("toString", () => { 5 | it("should return the correct string representation", () => { 6 | const header = "Test Header"; 7 | const value = "Test Value"; 8 | const number = 1; 9 | const otherRow = new OtherRow(number, header, value); 10 | 11 | const result = otherRow.toString(); 12 | 13 | expect(result).toBe(`${header}: ${value}`); 14 | }); 15 | }); 16 | }); -------------------------------------------------------------------------------- /src/Scripts/Poster.ts: -------------------------------------------------------------------------------- 1 | export class Poster { 2 | public static site() { return window.location.protocol + "//" + window.location.host; } 3 | 4 | public static postMessageToFrame(frame: Window, eventName: string, data?: unknown): void { 5 | if (frame) { 6 | frame.postMessage({ eventName: eventName, data: data }, Poster.site()); 7 | } 8 | } 9 | 10 | public static postMessageToParent(eventName: string, data?: unknown): void { 11 | window.parent.postMessage({ eventName: eventName, data: data }, Poster.site()); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Scripts/table/TableSection.ts: -------------------------------------------------------------------------------- 1 | export abstract class TableSection { 2 | public abstract readonly tableName: string; 3 | public abstract readonly displayName: string; 4 | public abstract readonly paneClass: "sectionHeader" | "tableCaption"; 5 | public abstract exists(): boolean; 6 | public abstract toString(): string; 7 | 8 | // Shared accessibility methods 9 | public getTableCaption(): string { 10 | return this.displayName; 11 | } 12 | 13 | public getAriaLabel(): string { 14 | return `${this.displayName} table`; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Pages/DesktopPane.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Redirection 6 | 7 | 8 | 11 | 12 | 13 | If you are not redirected automatically, follow this link to example. 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Pages/Default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Redirection 6 | 7 | 8 | 11 | 12 | 13 | If you are not redirected automatically, follow this link to example. 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Pages/DefaultPhone.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Redirection 6 | 7 | 8 | 11 | 12 | 13 | If you are not redirected automatically, follow this link to example. 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Pages/DefaultTablet.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Redirection 6 | 7 | 8 | 11 | 12 | 13 | If you are not redirected automatically, follow this link to example. 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Pages/MobilePane.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Redirection 6 | 7 | 8 | 11 | 12 | 13 | If you are not redirected automatically, follow this link to example. 14 | 15 | 16 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "ms-edgedevtools.vscode-edge-devtools", 8 | "msoffice.microsoft-office-add-in-debugger", 9 | "dbaeumer.vscode-eslint", 10 | "esbenp.prettier-vscode", 11 | "orta.vscode-jest" 12 | ], 13 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 14 | "unwantedRecommendations": [] 15 | } 16 | -------------------------------------------------------------------------------- /src/Scripts/DateWithNum.test.ts: -------------------------------------------------------------------------------- 1 | import { DateWithNum } from "./DateWithNum"; 2 | 3 | describe("DateWithNum", () => { 4 | it("should create an instance with the correct dateNum and date", () => { 5 | const dateNum = 1; 6 | const date = "2023-10-01"; 7 | const dateWithNum = new DateWithNum(dateNum, date); 8 | 9 | expect(dateWithNum.dateNum).toBe(dateNum); 10 | expect(dateWithNum.date).toBe(date); 11 | }); 12 | 13 | it("should return the correct date string when toString is called", () => { 14 | const dateNum = 1; 15 | const date = "2023-10-01"; 16 | const dateWithNum = new DateWithNum(dateNum, date); 17 | 18 | expect(dateWithNum.toString()).toBe(date); 19 | }); 20 | }); -------------------------------------------------------------------------------- /src/Scripts/table/DataTable.ts: -------------------------------------------------------------------------------- 1 | import { TableSection } from "./TableSection"; 2 | 3 | export abstract class DataTable extends TableSection { 4 | protected abstract sortColumnInternal: string; 5 | protected abstract sortOrderInternal: number; 6 | public abstract doSort(col: string): void; 7 | public abstract get rows(): unknown[]; // typed per implementation 8 | 9 | public readonly paneClass = "tableCaption" as const; 10 | 11 | public get sortColumn(): string { return this.sortColumnInternal; } 12 | public get sortOrder(): number { return this.sortOrderInternal; } 13 | 14 | // Default exists implementation for data tables 15 | public exists(): boolean { 16 | return this.rows.length > 0; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Scripts/Strings.test.ts: -------------------------------------------------------------------------------- 1 | import { Strings } from "./Strings"; 2 | 3 | describe("joinArray Tests", () => { 4 | test("null", () => { expect(Strings.joinArray(null, " : ")).toBe(""); }); 5 | test("[\"1\"]", () => { expect(Strings.joinArray(["1"], " : ")).toBe("1"); }); 6 | test("[\"1\", \"2\"]", () => { expect(Strings.joinArray(["1", "2"], " : ")).toBe("1 : 2"); }); 7 | test("[null, \"2\"]", () => { expect(Strings.joinArray([null, "2"], " : ")).toBe("2"); }); 8 | test("[\"1\", null]", () => { expect(Strings.joinArray(["1", null], " : ")).toBe("1"); }); 9 | test("[\"1\", null, \"3\"]", () => { expect(Strings.joinArray(["1", null, "3"], " : ")).toBe("1 : 3"); }); 10 | test("[1 : 2]", () => { expect(Strings.joinArray([1, 2], " : ")).toBe("1 : 2"); }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/Scripts/ui/uiToggle.ts: -------------------------------------------------------------------------------- 1 | import { 2 | fluentButton, 3 | fluentCheckbox, 4 | fluentDialog, 5 | fluentRadio, 6 | fluentRadioGroup, 7 | fluentToolbar, 8 | provideFluentDesignSystem 9 | } from "@fluentui/web-components"; 10 | import "../../Content/fluentCommon.css"; 11 | import "../../Content/uiToggle.css"; 12 | 13 | import { ParentFrame } from "../ParentFrame"; 14 | 15 | // Register Fluent UI Web Components 16 | provideFluentDesignSystem().register( 17 | fluentButton(), 18 | fluentCheckbox(), 19 | fluentDialog(), 20 | fluentRadio(), 21 | fluentRadioGroup(), 22 | fluentToolbar() 23 | ); 24 | 25 | Office.onReady(async (info) => { 26 | if (info.host === Office.HostType.Outlook) { 27 | await ParentFrame.initUI(); 28 | } 29 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "moduleResolution": "node", 5 | "module": "ES2022", 6 | "target": "ES2022", 7 | "lib": ["ES2022", "DOM"], 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "sourceMap": true, 11 | "allowUnreachableCode": false, 12 | "allowUnusedLabels": false, 13 | "exactOptionalPropertyTypes": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitOverride": true, 17 | "noImplicitReturns": true, 18 | "noPropertyAccessFromIndexSignature": true, 19 | "noUncheckedIndexedAccess": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "strict": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Outlook Desktop (Edge Chromium)", 9 | "type": "msedge", 10 | "request": "attach", 11 | "useWebView": true, 12 | "port": 9229, 13 | "timeout": 600000, 14 | "webRoot": "${workspaceFolder}", 15 | "sourceMapPathOverrides": { 16 | "webpack://mha/./src/*": "${workspaceFolder}/src/*", 17 | "webpack://mha/*": "${workspaceFolder}/*", 18 | "/src/*": "${workspaceFolder}/src/*" 19 | }, 20 | "preLaunchTask": "Debug: Outlook Desktop" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/jest.yml: -------------------------------------------------------------------------------- 1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy 2 | # More GitHub Actions for Azure: https://github.com/Azure/actions 3 | 4 | name: Jest MHA 5 | 6 | on: 7 | workflow_dispatch: 8 | pull_request: 9 | merge_group: 10 | push: 11 | branches: 12 | - main 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build-test: 19 | runs-on: windows-latest 20 | 21 | steps: 22 | - name: Harden Runner 23 | uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 24 | with: 25 | egress-policy: audit 26 | 27 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 28 | 29 | - name: NPM build, test 30 | run: | 31 | npm ci 32 | npm test 33 | -------------------------------------------------------------------------------- /tasks/clean.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = path.dirname(__filename); 7 | 8 | const rmdir = function (filepath) { 9 | if (fs.existsSync(filepath)) { 10 | fs.readdirSync(filepath).forEach((file) => { 11 | const subpath = path.join(filepath, file); 12 | if (fs.lstatSync(subpath).isDirectory()) { 13 | rmdir(subpath); 14 | } else { 15 | fs.unlinkSync(subpath); 16 | } 17 | }); 18 | 19 | console.log("Deleting " + filepath); 20 | fs.rmdirSync(filepath); 21 | } 22 | }; 23 | 24 | // Remove build output directories 25 | rmdir(path.join(__dirname, "..", "Pages")); 26 | rmdir(path.join(__dirname, "..", "Resources")); 27 | -------------------------------------------------------------------------------- /src/Scripts/row/Row.ts: -------------------------------------------------------------------------------- 1 | export class Row { 2 | constructor(header: string, label: string, headerName: string) { 3 | this.header = header; 4 | this.label = label; 5 | this.headerName = headerName; 6 | this.url = ""; 7 | this.valueInternal = ""; 8 | } 9 | 10 | [index: string]: unknown; 11 | protected valueInternal: string; 12 | header: string; 13 | label: string; 14 | headerName: string; 15 | url: string; 16 | onGetUrl?: (headerName: string, value: string) => string; 17 | 18 | public set value(value: string) { this.valueInternal = value; } 19 | get value(): string { return this.valueInternal; } 20 | get valueUrl(): string { return this.onGetUrl ? this.onGetUrl(this.headerName, this.valueInternal) : ""; } 21 | get id(): string { return this.header + "_id"; } 22 | 23 | toString(): string { return this.label + ": " + this.value; } 24 | } -------------------------------------------------------------------------------- /src/Scripts/row/Match.test.ts: -------------------------------------------------------------------------------- 1 | import { Match } from "./Match"; 2 | 3 | describe("Match", () => { 4 | it("should create an instance with the given fieldName and iToken", () => { 5 | const fieldName = "testField"; 6 | const iToken = 123; 7 | const match = new Match(fieldName, iToken); 8 | 9 | expect(match.fieldName).toBe(fieldName); 10 | expect(match.iToken).toBe(iToken); 11 | }); 12 | 13 | it("should have readonly properties", () => { 14 | const match = new Match("testField", 123); 15 | 16 | expect(() => { 17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 18 | (match as any).fieldName = "newField"; 19 | }).toThrow(); 20 | 21 | expect(() => { 22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 23 | (match as any).iToken = 456; 24 | }).toThrow(); 25 | }); 26 | }); -------------------------------------------------------------------------------- /src/Pages/classicDesktopFrame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Classic Desktop Frame 6 | 7 | 8 | 9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | 21 |
22 |
23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Content/privacy.css: -------------------------------------------------------------------------------- 1 | /* Privacy Policy Page Specific Styles */ 2 | @import url("themeColors.css"); 3 | 4 | /* Make privacy policy page always scrollable */ 5 | body.privacy-policy { 6 | overflow-y: auto; 7 | max-height: 100vh; 8 | } 9 | 10 | .close-button { 11 | position: fixed; 12 | top: 8px; 13 | right: 10px; 14 | background-color: var(--primary-blue); 15 | color: var(--white); 16 | border: none; 17 | border-radius: 3px; 18 | padding: 4px 8px; 19 | cursor: pointer; 20 | font-family: "Segoe UI", Arial, sans-serif; 21 | font-size: 12px; 22 | font-weight: 600; 23 | z-index: 1000; 24 | min-width: auto; 25 | height: auto; 26 | } 27 | 28 | .close-button:hover { 29 | background-color: var(--hover-blue-link); 30 | } 31 | 32 | .close-button:focus { 33 | outline: 2px solid var(--focus-blue); 34 | outline-offset: 2px; 35 | } 36 | 37 | .close-button:active { 38 | background-color: var(--focus-blue); 39 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | open-pull-requests-limit: 10 13 | 14 | groups: 15 | production-dependencies: 16 | dependency-type: "production" 17 | development-dependencies: 18 | dependency-type: "development" 19 | 20 | - package-ecosystem: github-actions 21 | directory: / 22 | schedule: 23 | interval: daily 24 | 25 | - package-ecosystem: docker 26 | directory: / 27 | schedule: 28 | interval: daily 29 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type {Config} from "jest"; 2 | // https://github.com/jest-community/awesome-jest 3 | const config: Config = { 4 | testEnvironment: "jsdom", 5 | globalSetup: "./global-setup.js", 6 | transform: { 7 | "^.+.tsx?$": ["ts-jest",{ diagnostics: { ignoreCodes: ["TS151001"] } }], 8 | }, 9 | globals: { 10 | "__AIKEY__": "" 11 | }, 12 | collectCoverage: true, 13 | collectCoverageFrom: ["./src/**"], 14 | coverageDirectory: "./Pages/coverage", 15 | coverageReporters: ["json", "lcov", "text", "clover", "text-summary"], 16 | coverageThreshold: { 17 | global: { 18 | branches: 35, 19 | functions: 40, 20 | lines: 40, 21 | statements: 40, 22 | }, 23 | }, 24 | reporters: [ 25 | "default", 26 | ["jest-html-reporters", { 27 | "publicPath": "./Pages/test", 28 | "filename": "index.html" 29 | }] 30 | ] 31 | }; 32 | 33 | export default config; -------------------------------------------------------------------------------- /Web.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Scripts/table/Column.test.ts: -------------------------------------------------------------------------------- 1 | import { Column } from "./Column"; 2 | 3 | describe("column", () => { 4 | it("should create an instance with the given id, label, and class", () => { 5 | const id = "col1"; 6 | const label = "Column 1"; 7 | const columnClass = "class1"; 8 | const col = new Column(id, label, columnClass); 9 | 10 | expect(col.id).toBe(id); 11 | expect(col.label).toBe(label); 12 | expect(col.class).toBe(columnClass); 13 | }); 14 | 15 | it("should have id as a string", () => { 16 | const col = new Column("col2", "Column 2", "class2"); 17 | expect(typeof col.id).toBe("string"); 18 | }); 19 | 20 | it("should have label as a string", () => { 21 | const col = new Column("col3", "Column 3", "class3"); 22 | expect(typeof col.label).toBe("string"); 23 | }); 24 | 25 | it("should have class as a string", () => { 26 | const col = new Column("col4", "Column 4", "class4"); 27 | expect(typeof col.class).toBe("string"); 28 | }); 29 | }); -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, 4 | # surfacing known-vulnerable versions of the packages declared or updated in the PR. 5 | # Once installed, if the workflow run is marked as required, 6 | # PRs introducing known-vulnerable packages will be blocked from merging. 7 | # 8 | # Source repository: https://github.com/actions/dependency-review-action 9 | name: 'Dependency Review' 10 | on: 11 | pull_request: 12 | merge_group: 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | dependency-review: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Harden Runner 22 | uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 23 | with: 24 | egress-policy: audit 25 | 26 | - name: 'Checkout Repository' 27 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 28 | - name: 'Dependency Review' 29 | uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | ===== 3 | 4 | Copyright (c) 2017 Microsoft 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/Content/.csslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "adjoining-classes": true, 3 | "box-model": true, 4 | "box-sizing": false, 5 | "bulletproof-font-face": true, 6 | "compatible-vendor-prefixes": true, 7 | "display-property-grouping": 2, 8 | "duplicate-background-images": true, 9 | "duplicate-properties": true, 10 | "empty-rules": 2, 11 | "errors": true, 12 | "fallback-colors": true, 13 | "floats": true, 14 | "font-faces": true, 15 | "font-sizes": true, 16 | "gradients": true, 17 | "ids": false, 18 | "import": true, 19 | "important": true, 20 | "known-properties": true, 21 | "outline-none": true, 22 | "overqualified-elements": true, 23 | "qualified-headings": true, 24 | "regex-selectors": true, 25 | "rules-count": true, 26 | "selector-max": true, 27 | "selector-max-approaching": true, 28 | "shorthand": 2, 29 | "star-property-hack": true, 30 | "text-indent": true, 31 | "underscore-property-hack": true, 32 | "unique-headings": true, 33 | "universal-selector": true, 34 | "unqualified-attributes": true, 35 | "vendor-prefix": true, 36 | "zero-units": true 37 | } 38 | -------------------------------------------------------------------------------- /src/Scripts/ui/privacy.ts: -------------------------------------------------------------------------------- 1 | import "../../Content/Office.css"; 2 | import "../../Content/privacy.css"; 3 | 4 | // Initialize privacy policy page 5 | document.addEventListener("DOMContentLoaded", () => { 6 | const closeButton = document.getElementById("close-button"); 7 | 8 | // Function to handle closing/going back 9 | const handleClose = () => { 10 | // Try to go back to the previous page first 11 | if (window.history.length > 1) { 12 | window.history.back(); 13 | } else { 14 | // If no history, try to close the window/tab 15 | window.close(); 16 | 17 | // If window.close() doesn't work (some browsers prevent it), 18 | // navigate back to the main add-in page 19 | setTimeout(() => { 20 | window.location.href = "uitoggle.html"; 21 | }, 100); 22 | } 23 | }; 24 | 25 | // Handle close button click 26 | if (closeButton) { 27 | closeButton.addEventListener("click", handleClose); 28 | } 29 | 30 | // Handle Escape key press 31 | document.addEventListener("keydown", (event) => { 32 | if (event.key === "Escape") { 33 | handleClose(); 34 | } 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/Scripts/row/ArchivedRow.test.ts: -------------------------------------------------------------------------------- 1 | import { ArchivedRow } from "./ArchivedRow"; 2 | import { Strings } from "../Strings"; 3 | 4 | jest.mock("../Strings", () => ({ 5 | // eslint-disable-next-line @typescript-eslint/naming-convention 6 | Strings: { 7 | mapHeaderToURL: jest.fn(), 8 | mapValueToURL: jest.fn() 9 | } 10 | })); 11 | 12 | describe("ArchivedRow", () => { 13 | const header = "testHeader"; 14 | const label = "testLabel"; 15 | let archivedRow: ArchivedRow; 16 | 17 | beforeEach(() => { 18 | (Strings.mapHeaderToURL as jest.Mock).mockReturnValue("mockedHeaderURL"); 19 | (Strings.mapValueToURL as jest.Mock).mockReturnValue("mockedValueURL"); 20 | archivedRow = new ArchivedRow(header, label); 21 | }); 22 | 23 | it("should set url using Strings.mapHeaderToURL", () => { 24 | expect(Strings.mapHeaderToURL).toHaveBeenCalledWith(header, label); 25 | expect(archivedRow.url).toBe("mockedHeaderURL"); 26 | }); 27 | 28 | it("should return valueUrl using Strings.mapValueToURL", () => { 29 | archivedRow["valueInternal"] = "internalValue"; 30 | expect(archivedRow.valueUrl).toBe("mockedValueURL"); 31 | expect(Strings.mapValueToURL).toHaveBeenCalledWith("internalValue"); 32 | }); 33 | }); -------------------------------------------------------------------------------- /src/Scripts/row/SummaryRow.test.ts: -------------------------------------------------------------------------------- 1 | import { SummaryRow } from "./SummaryRow"; 2 | import { Strings } from "../Strings"; 3 | 4 | // Mock the Strings.mapHeaderToURL method 5 | jest.mock("../Strings", () => ({ 6 | // eslint-disable-next-line @typescript-eslint/naming-convention 7 | Strings: { 8 | mapHeaderToURL: jest.fn() 9 | } 10 | })); 11 | 12 | describe("SummaryRow", () => { 13 | beforeEach(() => { 14 | // Clear all instances and calls to constructor and all methods: 15 | (Strings.mapHeaderToURL as jest.Mock).mockClear(); 16 | }); 17 | 18 | it("should call Strings.mapHeaderToURL with correct parameters", () => { 19 | const header = "testHeader"; 20 | const label = "testLabel"; 21 | const url = "testURL"; 22 | 23 | (Strings.mapHeaderToURL as jest.Mock).mockReturnValue(url); 24 | 25 | const summaryRow = new SummaryRow(header, label); 26 | 27 | expect(Strings.mapHeaderToURL).toHaveBeenCalledWith(header, label); 28 | expect(summaryRow.url).toBe(url); 29 | }); 30 | 31 | it("should set the url property correctly", () => { 32 | const header = "anotherHeader"; 33 | const label = "anotherLabel"; 34 | const url = "anotherURL"; 35 | 36 | (Strings.mapHeaderToURL as jest.Mock).mockReturnValue(url); 37 | 38 | const summaryRow = new SummaryRow(header, label); 39 | 40 | expect(summaryRow.url).toBe(url); 41 | }); 42 | }); -------------------------------------------------------------------------------- /src/Scripts/row/CreationRow.test.ts: -------------------------------------------------------------------------------- 1 | import { CreationRow } from "./CreationRow"; 2 | import { SummaryRow } from "./SummaryRow"; 3 | import { Strings } from "../Strings"; 4 | 5 | jest.mock("../Strings", () => ({ 6 | // eslint-disable-next-line @typescript-eslint/naming-convention 7 | Strings: { 8 | mapHeaderToURL: jest.fn((header, label) => `url-for-${header}-${label}`) 9 | } 10 | })); 11 | 12 | describe("CreationRow", () => { 13 | let creationRow: CreationRow; 14 | 15 | beforeEach(() => { 16 | creationRow = new CreationRow("header", "label"); 17 | }); 18 | 19 | it("should be an instance of SummaryRow", () => { 20 | expect(creationRow).toBeInstanceOf(SummaryRow); 21 | }); 22 | 23 | it("should initialize url using Strings.mapHeaderToURL", () => { 24 | expect(Strings.mapHeaderToURL).toHaveBeenCalledWith("header", "label"); 25 | expect(creationRow.url).toBe("url-for-header-label"); 26 | }); 27 | 28 | it("should initialize postFix to an empty string", () => { 29 | expect(creationRow.postFix).toBe(""); 30 | }); 31 | 32 | it("should get value correctly", () => { 33 | creationRow["valueInternal"] = "internalValue"; 34 | creationRow.postFix = "PostFix"; 35 | expect(creationRow.value).toBe("internalValuePostFix"); 36 | }); 37 | 38 | it("should set value correctly", () => { 39 | creationRow.value = "newValue"; 40 | expect(creationRow["valueInternal"]).toBe("newValue"); 41 | }); 42 | }); -------------------------------------------------------------------------------- /src/Scripts/row/ReceivedField.test.ts: -------------------------------------------------------------------------------- 1 | import { ReceivedField } from "./ReceivedField"; 2 | 3 | describe("ReceivedField", () => { 4 | it("should initialize with given label and value", () => { 5 | const field = new ReceivedField("Test Label", "Test Value"); 6 | expect(field.label).toBe("Test Label"); 7 | expect(field.value).toBe("Test Value"); 8 | }); 9 | 10 | it("should initialize with given label and default value when value is undefined", () => { 11 | const field = new ReceivedField("Test Label"); 12 | expect(field.label).toBe("Test Label"); 13 | expect(field.value).toBe(""); 14 | }); 15 | 16 | it("should initialize with given label and null value", () => { 17 | const field = new ReceivedField("Test Label", null); 18 | expect(field.label).toBe("Test Label"); 19 | expect(field.value).toBeNull(); 20 | }); 21 | 22 | it("should return value as string when value is a string", () => { 23 | const field = new ReceivedField("Test Label", "Test Value"); 24 | expect(field.toString()).toBe("Test Value"); 25 | }); 26 | 27 | it("should return value as string when value is a number", () => { 28 | const field = new ReceivedField("Test Label", 123); 29 | expect(field.toString()).toBe("123"); 30 | }); 31 | 32 | it("should return \"null\" when value is null", () => { 33 | const field = new ReceivedField("Test Label", null); 34 | expect(field.toString()).toBe("null"); 35 | }); 36 | }); -------------------------------------------------------------------------------- /src/Scripts/MHADates.test.ts: -------------------------------------------------------------------------------- 1 | import { DateWithNum } from "./DateWithNum"; 2 | import { MHADates } from "./MHADates"; 3 | 4 | describe("MHADates.parseDate", () => { 5 | it("should parse date in YYYY-MM-DD format", () => { 6 | const dateStr = "2018-01-28"; 7 | const result = MHADates.parseDate(dateStr); 8 | expect(result).toBeInstanceOf(DateWithNum); 9 | expect(result.dateNum).toBe(new Date("01/28/2018 00:00:00 +0000").valueOf()); 10 | }); 11 | 12 | it("should parse date in MM-DD-YYYY format", () => { 13 | const dateStr = "01-28-2018"; 14 | const result = MHADates.parseDate(dateStr); 15 | expect(result).toBeInstanceOf(DateWithNum); 16 | expect(result.dateNum).toBe(new Date("01/28/2018 00:00:00 +0000").valueOf()); 17 | }); 18 | 19 | it("should handle date with time and milliseconds", () => { 20 | const dateStr = "2018-01-28 12:34:56.789"; 21 | const result = MHADates.parseDate(dateStr); 22 | expect(result).toBeInstanceOf(DateWithNum); 23 | expect(result.dateNum).toBe(new Date("01/28/2018 12:34:56 +0000").valueOf() + 789); 24 | }); 25 | 26 | it("should handle date without timezone offset", () => { 27 | const dateStr = "2018-01-28 12:34:56"; 28 | const result = MHADates.parseDate(dateStr); 29 | expect(result).toBeInstanceOf(DateWithNum); 30 | expect(result.dateNum).toBe(new Date("01/28/2018 12:34:56 +0000").valueOf()); 31 | }); 32 | 33 | // TODO: Figure out how to mock a failure to load dayjs 34 | }); -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING 2 | 3 | All pull requests are welcome, there are just a few guidelines you need to follow. 4 | 5 | When contributing to this repository, please first discuss the change by creating a new [issue](https://github.com/microsoft/mha/issues) or by replying to an existing one. 6 | 7 | ## GETTING STARTED 8 | 9 | * Make sure you have a [GitHub account](https://github.com/signup/free). 10 | * Fork the repository, you can [learn about forking on Github](https://help.github.com/articles/fork-a-repo) 11 | * [Clone the repro to your local machine](https://help.github.com/articles/cloning-a-repository/) like so: 12 | ```git clone --recursive https://github.com/microsoft/mha.git``` 13 | 14 | ## MAKING CHANGES 15 | 16 | * Create branch topic for the work you will do, this is where you want to base your work. 17 | * This is usually the main branch. 18 | * To quickly create a topic branch based on main, run 19 | ```git checkout -b u/username/topic main``` 20 | * *Make sure to substitute your own name and topic in this command* * 21 | * Once you have a branch, make your changes and commit them to the local branch. 22 | * All submissions require a review and pull requests are how those happen. Consult 23 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 24 | information on pull requests. 25 | 26 | ## SUBMITTING CHANGES 27 | 28 | * Push your changes to a topic branch in your fork of the repository. 29 | 30 | ## PUSH TO YOUR FORK AND SUBMIT A PULL REQUEST 31 | 32 | At this point you're waiting on the code/changes to be reviewed. 33 | -------------------------------------------------------------------------------- /src/Scripts/jestMatchers/arrayEqual.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "@jest/globals"; 2 | import type { MatcherFunction, SyncExpectationResult } from "expect"; 3 | 4 | import { receivedEqual } from "./receivedEqual"; 5 | import { ReceivedRow } from "../row/ReceivedRow"; 6 | 7 | const arrayEqual: MatcherFunction<[expected: { [index: string]: string }[]]> = 8 | async function (actualUnknown: unknown, expected: { [index: string]: string }[]) { 9 | const actual = actualUnknown as ReceivedRow[]; 10 | const messages: string[] = []; 11 | let passed = true; 12 | 13 | if (actual.length !== expected.length) { 14 | passed = false; 15 | messages.push("length = " + actual.length); 16 | messages.push("length = " + expected.length); 17 | } 18 | 19 | for (let i = 0; i < actual.length; i++) { 20 | const expectedValue = expected[i] as { [index: string]: string }; 21 | const result: SyncExpectationResult = await receivedEqual.call(this, actual[i], expectedValue); 22 | if (!result.pass) { 23 | passed = false; 24 | messages.push("[" + i + "]"); 25 | messages.push(result.message()); 26 | } 27 | } 28 | 29 | return { 30 | message: () => messages.join("\n"), 31 | pass: passed 32 | }; 33 | }; 34 | 35 | expect.extend({ arrayEqual, }); 36 | 37 | declare module "expect" { 38 | interface Matchers { 39 | arrayEqual(expected: { [index: string]: string }[] ): R; 40 | } 41 | } -------------------------------------------------------------------------------- /src/Scripts/jestMatchers/datesEqual.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "@jest/globals"; 2 | import type { MatcherFunction } from "expect"; 3 | 4 | import { DateWithNum } from "../DateWithNum"; 5 | import { ReceivedRow } from "../row/ReceivedRow"; 6 | 7 | export const datesEqual: MatcherFunction<[expected: DateWithNum]> = 8 | function (actualUnknown: unknown, expected: DateWithNum) { 9 | const actual = actualUnknown as ReceivedRow; 10 | let passed = true; 11 | const messages: string[] = []; 12 | 13 | if (actual.date === undefined) { 14 | passed = false; 15 | messages.push("date is undefined"); 16 | } else { 17 | const date = new Date(actual.date.value ?? ""); 18 | const dateStr = date.toLocaleString("en-US", { timeZone: "America/New_York" }); 19 | if (dateStr !== expected.date) { 20 | passed = false; 21 | messages.push(`date: ${dateStr} !== ${expected.date}`); 22 | } 23 | 24 | const dateNum = actual.dateNum.toString(); 25 | if (dateNum !== expected.dateNum.toString()) { 26 | passed = false; 27 | messages.push(`dateNum: ${dateNum} !== ${expected.dateNum}`); 28 | } 29 | } 30 | 31 | return { 32 | pass: passed, 33 | message: () => messages.join("; "), 34 | }; 35 | }; 36 | 37 | expect.extend({ datesEqual, }); 38 | 39 | declare module "expect" { 40 | interface Matchers { 41 | datesEqual(expected: DateWithNum ): R; 42 | } 43 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy 2 | # More GitHub Actions for Azure: https://github.com/Azure/actions 3 | 4 | name: Build mha 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | runs-on: windows-latest 18 | 19 | steps: 20 | - name: Harden Runner 21 | uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 22 | with: 23 | egress-policy: audit 24 | 25 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 26 | 27 | - name: Set up Node.js 28 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 29 | with: 30 | node-version: ${{ env.NODE_VERSION }} 31 | cache: 'npm' 32 | 33 | - name: NPM install, build 34 | env: 35 | SCM_COMMIT_ID: ${{ github.sha }} 36 | APPINSIGHTS_INSTRUMENTATIONKEY: ${{ secrets.APPINSIGHTS_INSTRUMENTATIONKEY }} 37 | run: | 38 | npm ci 39 | npm run build --if-present 40 | 41 | - name: Upload artifact for deployment job 42 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 43 | with: 44 | name: mha-assets 45 | path: | 46 | Pages 47 | Resources 48 | favicon.ico 49 | Manifest* 50 | LICENSE 51 | README.md 52 | package.json 53 | .deployment 54 | deploy.cmd 55 | Web.config 56 | -------------------------------------------------------------------------------- /src/Content/Office.css: -------------------------------------------------------------------------------- 1 | @import url("themeColors.css"); 2 | 3 | /* Base 4 | ***************************************************************/ 5 | html { overflow: hidden; } 6 | body { margin: 0; padding: 1em; font-size: 62.5%; line-height: 1.4em; } 7 | 8 | /* Typography 9 | ***************************************************************/ 10 | body { font-size:.75em; font-family: "Segoe WP ", "Segoe UI", "Arial", sans-serif; color: var(--text-secondary); } 11 | h1, h2, h3{ font-family:"Segoe WP Light", "Segoe UI", "Arial", Sans-Serif; font-weight: normal; } 12 | h4, h5, h6, th { font-family:"Segoe WP Semibold", "Segoe UI", "Arial", Sans-Serif; font-weight: normal; } 13 | h1 { font-size: 1.8em; line-height:.95em; } 14 | h2 { font-size: 1.4em; line-height: 1.1em; } 15 | h3 { font-size: 1.1em; } 16 | h4, h5, h6 { font-size: 1em; } 17 | textarea, button { font-family: "Segoe WP Semibold", "Segoe UI", "Arial", sans-serif; color: var(--text-primary); } 18 | 19 | /* Links 20 | ***************************************************************/ 21 | a { color: var(--link-default); text-decoration: underline; } 22 | a:visited { color: var(--link-visited); } 23 | a:hover { color: var(--link-hover); text-decoration: none; } 24 | a:focus { 25 | outline: var(--focus-blue) solid 2px; 26 | outline-offset: 2px; 27 | } 28 | a:hover, a:active { outline: 0; } 29 | h1 a { text-decoration: none; } 30 | 31 | /* Grouping 32 | ***************************************************************/ 33 | div { padding-bottom: 1em; } 34 | ul { margin-left: 1em; padding: 0; } 35 | p { padding: 0; } 36 | hr { border: none; height: 1px; color: var(--border-light-gray); background-color: var(--border-light-gray); } 37 | 38 | /* Tables 39 | ***************************************************************/ 40 | th, td { text-align: left; vertical-align: top; padding: .4em 1.2em 1em 0; line-height: 1.3em; } 41 | -------------------------------------------------------------------------------- /MHA.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.1.32421.90 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{E24C65DC-7377-472B-9ABA-BC803B73C61A}") = "MHA", ".", "{41B51386-0049-4C99-AAB7-3ABBBA0942D9}" 7 | ProjectSection(WebsiteProperties) = preProject 8 | TargetFrameworkMoniker = ".NETFramework,Version%3Dv4.8" 9 | Debug.AspNetCompiler.VirtualPath = "/localhost_55016" 10 | Debug.AspNetCompiler.PhysicalPath = "..\MHA\" 11 | Debug.AspNetCompiler.TargetPath = "PrecompiledWeb\localhost_55016\" 12 | Debug.AspNetCompiler.Updateable = "true" 13 | Debug.AspNetCompiler.ForceOverwrite = "true" 14 | Debug.AspNetCompiler.FixedNames = "false" 15 | Debug.AspNetCompiler.Debug = "True" 16 | Release.AspNetCompiler.VirtualPath = "/localhost_55016" 17 | Release.AspNetCompiler.PhysicalPath = "..\MHA\" 18 | Release.AspNetCompiler.TargetPath = "PrecompiledWeb\localhost_55016\" 19 | Release.AspNetCompiler.Updateable = "true" 20 | Release.AspNetCompiler.ForceOverwrite = "true" 21 | Release.AspNetCompiler.FixedNames = "false" 22 | Release.AspNetCompiler.Debug = "False" 23 | VWDPort = "55016" 24 | SlnRelativePath = "..\MHA\" 25 | EndProjectSection 26 | EndProject 27 | Global 28 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 29 | Debug|Any CPU = Debug|Any CPU 30 | EndGlobalSection 31 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 32 | {41B51386-0049-4C99-AAB7-3ABBBA0942D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {41B51386-0049-4C99-AAB7-3ABBBA0942D9}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | EndGlobalSection 35 | GlobalSection(SolutionProperties) = preSolution 36 | HideSolutionNode = FALSE 37 | EndGlobalSection 38 | GlobalSection(ExtensibilityGlobals) = postSolution 39 | SolutionGuid = {B5AFD960-D224-4BB2-9A1C-E190128E2839} 40 | EndGlobalSection 41 | EndGlobal 42 | -------------------------------------------------------------------------------- /src/Scripts/table/Other.ts: -------------------------------------------------------------------------------- 1 | import { DataTable } from "./DataTable"; 2 | import { mhaStrings } from "../mhaStrings"; 3 | import { Header } from "../row/Header"; 4 | import { OtherRow } from "../row/OtherRow"; 5 | 6 | export class Other extends DataTable { 7 | private otherRows: OtherRow[] = []; 8 | protected sortColumnInternal = "number"; 9 | protected sortOrderInternal = 1; 10 | public readonly tableName: string = "otherHeaders"; 11 | public readonly displayName: string = mhaStrings.mhaOtherHeaders; 12 | 13 | public get rows(): OtherRow[] { return this.otherRows; } 14 | 15 | public override doSort(col: string): void { 16 | if (this.sortColumnInternal === col) { 17 | this.sortOrderInternal *= -1; 18 | } else { 19 | this.sortColumnInternal = col; 20 | this.sortOrderInternal = 1; 21 | } 22 | 23 | if (this.rows[0] && this.sortColumnInternal + "Sort" in this.rows[0]) { 24 | col = col + "Sort"; 25 | } 26 | 27 | this.rows.sort((a: OtherRow, b: OtherRow) => { 28 | return this.sortOrderInternal * ((a[col] as string | number) < (b[col] as string | number) ? -1 : 1); 29 | }); 30 | } 31 | 32 | public add(header: Header): boolean { 33 | if (header.header || header.value) { 34 | this.rows.push(new OtherRow( 35 | this.rows.length + 1, 36 | header.header, 37 | header.value)); 38 | return true; 39 | } 40 | 41 | return false; 42 | } 43 | 44 | public override exists() { return this.rows.length > 0; } 45 | 46 | public override toString() { 47 | if (!this.exists()) return ""; 48 | const ret: string[] = ["Other"]; 49 | this.rows.forEach(function (row) { 50 | if (row.value) { ret.push(row.value); } 51 | }); 52 | return ret.join("\n"); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Scripts/ui/getHeaders/GetHeadersAPI.ts: -------------------------------------------------------------------------------- 1 | import { GetHeaders } from "./GetHeaders"; 2 | import { diagnostics } from "../../Diag"; 3 | import { Errors } from "../../Errors"; 4 | import { mhaStrings } from "../../mhaStrings"; 5 | import { ParentFrame } from "../../ParentFrame"; 6 | 7 | /* 8 | * GetHeadersAPI.js 9 | * 10 | * This file has all the methods to get PR_TRANSPORT_MESSAGE_HEADERS 11 | * from the current message via getAllInternetHeadersAsync. 12 | * 13 | * Requirement Sets and Permissions 14 | * getAllInternetHeadersAsync requires 1.9 and ReadItem 15 | */ 16 | 17 | export class GetHeadersAPI { 18 | private static minAPISet = "1.9"; 19 | 20 | public static canUseAPI(): boolean { return GetHeaders.canUseAPI("API", GetHeadersAPI.minAPISet); } 21 | 22 | private static async getAllInternetHeaders(item: Office.MessageRead): Promise { 23 | return new Promise((resolve) => { 24 | item.getAllInternetHeadersAsync((asyncResult) => { 25 | if (asyncResult.status === Office.AsyncResultStatus.Succeeded) { 26 | resolve(asyncResult.value); 27 | } else { 28 | diagnostics.set("getAllInternetHeadersAsyncFailure", JSON.stringify(asyncResult)); 29 | Errors.log(asyncResult.error, "getAllInternetHeadersAsync failed.\nFallback to Rest.\n" + JSON.stringify(asyncResult, null, 2), true); 30 | resolve(""); 31 | } 32 | }); 33 | }); 34 | } 35 | 36 | public static async send(): Promise { 37 | if (!GetHeaders.validItem() || !Office.context.mailbox.item) { 38 | Errors.logMessage("No item selected (API)"); 39 | return ""; 40 | } 41 | 42 | if (!GetHeadersAPI.canUseAPI()) { 43 | return ""; 44 | } 45 | 46 | ParentFrame.updateStatus(mhaStrings.mhaRequestSent); 47 | 48 | try { 49 | const headers = await GetHeadersAPI.getAllInternetHeaders(Office.context.mailbox.item); 50 | return headers; 51 | } 52 | catch (e) { 53 | Errors.log(e, "Failed in getAllInternetHeadersAsync"); 54 | } 55 | 56 | return ""; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Scripts/row/ReceivedRow.test.ts: -------------------------------------------------------------------------------- 1 | import { ReceivedField } from "./ReceivedField"; 2 | import { ReceivedRow } from "./ReceivedRow"; 3 | import { mhaStrings } from "../mhaStrings"; 4 | 5 | describe("ReceivedRow", () => { 6 | let receivedRow: ReceivedRow; 7 | 8 | beforeEach(() => { 9 | receivedRow = new ReceivedRow("Test Header"); 10 | }); 11 | 12 | test("should initialize fields correctly", () => { 13 | expect(receivedRow.sourceHeader).toBeInstanceOf(ReceivedField); 14 | expect(receivedRow.sourceHeader.value).toBe("Test Header"); 15 | expect(receivedRow.hop).toBeInstanceOf(ReceivedField); 16 | expect(receivedRow.hop.label).toBe(mhaStrings.mhaReceivedHop); 17 | // Add similar checks for other fields 18 | }); 19 | 20 | test("should set field value correctly", () => { 21 | receivedRow.setField("hop", "Test Hop"); 22 | expect(receivedRow.hop.value).toBe("Test Hop"); 23 | }); 24 | 25 | test("should append to existing field value", () => { 26 | receivedRow.setField("hop", "First Value"); 27 | receivedRow.setField("hop", "Second Value"); 28 | expect(receivedRow.hop.value).toBe("First Value; Second Value"); 29 | }); 30 | 31 | test("toString should return correct string representation", () => { 32 | receivedRow.setField("hop", "Test Hop"); 33 | receivedRow.setField("from", "Test From"); 34 | const result = receivedRow.toString(); 35 | expect(result).toContain(`${mhaStrings.mhaReceivedHop}: Test Hop`); 36 | expect(result).toContain(`${mhaStrings.mhaReceivedFrom}: Test From`); 37 | }); 38 | 39 | test("should not set field value if fieldName is empty", () => { 40 | receivedRow.setField("", "Test Value"); 41 | expect(receivedRow.sourceHeader.value).toBe("Test Header"); 42 | }); 43 | 44 | test("should not set field value if fieldValue is empty", () => { 45 | receivedRow.setField("hop", ""); 46 | expect(receivedRow.hop.value).toBe(""); 47 | }); 48 | 49 | test("should not set field value if field does not exist", () => { 50 | receivedRow.setField("nonExistentField", "Test Value"); 51 | expect(receivedRow["nonExistentField"]).toBeUndefined(); 52 | }); 53 | }); -------------------------------------------------------------------------------- /src/Pages/privacy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Message Header Analyzer Privacy Policy 6 | 7 | 8 | 9 | 10 |

Message Header Analyzer

11 |

Privacy Policy

12 |

TL;DR

13 | This add-in does not intentionally collect or transmit any personal data. All processing occurs client side. 14 | 15 |

Microsoft Privacy Statement

16 | This add-in adheres to the Microsoft Privacy Statement. 17 | 18 |

Telemetry

19 | This add-in uses Application Insights 20 | to collect telemetry necessary for maintainence and improvement of the add-in. No personal information is intentionally gathered. All information 21 | gathered is deleted after 90 days. 22 | 23 |

Questions

24 | Please refer any questions to the MHA project on GitHub.
25 |
26 |

A Note from the Developer on Permissions

27 | In order to get the transport message headers I have to use the EWS makeEwsRequestAsync method, which requires the ReadWriteMailbox permission level. See the article 28 | Understanding Outlook add-in permissions 29 | for more on this. If I could request fewer permissions I would, since I only ever read the one property, but I have no choice in the matter.
30 |
31 | When REST is more widely available, and a few REST specific bugs are fixed, I'll be able to switch to REST and request a lower permission level.
32 |
33 | Steve 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Scripts/table/Other.test.ts: -------------------------------------------------------------------------------- 1 | import { Other } from "./Other"; 2 | import { Header } from "../row/Header"; 3 | 4 | describe("Other", () => { 5 | let other: Other; 6 | 7 | beforeEach(() => { 8 | other = new Other(); 9 | }); 10 | 11 | test("should initialize with default values", () => { 12 | expect(other.tableName).toBe("otherHeaders"); 13 | expect(other.exists()).toBe(false); 14 | expect(other.toString()).toBe(""); 15 | }); 16 | 17 | test("should add a header and return true", () => { 18 | const header = new Header("header1", "value1"); 19 | const result = other.add(header); 20 | expect(result).toBe(true); 21 | expect(other.exists()).toBe(true); 22 | expect(other.rows.length).toBe(1); 23 | expect(other.rows[0] && other.rows[0].header).toBe("header1"); 24 | expect(other.rows[0] && other.rows[0].value).toBe("value1"); 25 | }); 26 | 27 | test("should not add an empty header and return false", () => { 28 | const header = new Header("", ""); 29 | const result = other.add(header); 30 | expect(result).toBe(false); 31 | expect(other.exists()).toBe(false); 32 | expect(other.rows.length).toBe(0); 33 | }); 34 | 35 | test("should sort rows by specified column", () => { 36 | const header1 = new Header("header1", "value1"); 37 | const header2 = new Header("header2", "value2"); 38 | other.add(header1); 39 | other.add(header2); 40 | 41 | other.doSort("header"); 42 | expect(other.rows[0] && other.rows[0].header).toBe("header1"); 43 | expect(other.rows[1] && other.rows[1].header).toBe("header2"); 44 | 45 | other.doSort("header"); 46 | expect(other.rows[0] && other.rows[0].header).toBe("header2"); 47 | expect(other.rows[1] && other.rows[1].header).toBe("header1"); 48 | }); 49 | 50 | test("should return correct string representation", () => { 51 | const header1 = new Header("header1", "value1"); 52 | const header2 = new Header("header2", "value2"); 53 | other.add(header1); 54 | other.add(header2); 55 | 56 | const result = other.toString(); 57 | expect(result).toBe("Other\nvalue1\nvalue2"); 58 | }); 59 | }); -------------------------------------------------------------------------------- /src/Scripts/row/ReceivedRow.ts: -------------------------------------------------------------------------------- 1 | import { mhaStrings } from "../mhaStrings"; 2 | import { ReceivedField } from "./ReceivedField"; 3 | 4 | export class ReceivedRow { 5 | constructor(receivedHeader: string | null) { 6 | this.sourceHeader = new ReceivedField("", receivedHeader); 7 | this.hop = new ReceivedField(mhaStrings.mhaReceivedHop); 8 | this.from = new ReceivedField(mhaStrings.mhaReceivedFrom); 9 | this.by = new ReceivedField(mhaStrings.mhaReceivedBy); 10 | this.with = new ReceivedField(mhaStrings.mhaReceivedWith); 11 | this.id = new ReceivedField(mhaStrings.mhaReceivedId); 12 | this.for = new ReceivedField(mhaStrings.mhaReceivedFor); 13 | this.via = new ReceivedField(mhaStrings.mhaReceivedVia); 14 | this.date = new ReceivedField(mhaStrings.mhaReceivedDate); 15 | this.delay = new ReceivedField(mhaStrings.mhaReceivedDelay); 16 | this.percent = new ReceivedField(mhaStrings.mhaReceivedPercent, 0); 17 | this.delaySort = new ReceivedField("", -1); 18 | this.dateNum = new ReceivedField(""); 19 | } 20 | [index: string]: ReceivedField | ((fieldName: string, fieldValue: string) => void) | (() => string); 21 | sourceHeader: ReceivedField; 22 | hop: ReceivedField; 23 | from: ReceivedField; 24 | by: ReceivedField; 25 | with: ReceivedField; 26 | id: ReceivedField; 27 | for: ReceivedField; 28 | via: ReceivedField; 29 | date: ReceivedField; 30 | delay: ReceivedField; 31 | percent: ReceivedField; 32 | delaySort: ReceivedField; 33 | dateNum: ReceivedField; 34 | 35 | setField(fieldName: string, fieldValue: string) { 36 | if (!fieldName || !fieldValue) { 37 | return; 38 | } 39 | 40 | const field = this[fieldName.toLowerCase()] as unknown as ReceivedField; 41 | if (!field) return; 42 | 43 | if (field.value) { field.value += "; " + fieldValue; } 44 | else { field.value = fieldValue; } 45 | } 46 | 47 | toString(): string { 48 | const str: string[] = []; 49 | for (const key in this) { 50 | const field = this[key] as ReceivedField; 51 | if (field && field.label && field.toString()) { 52 | str.push(field.label + ": " + field.toString()); 53 | } 54 | } 55 | 56 | return str.join("\n"); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Content/themeColors.css: -------------------------------------------------------------------------------- 1 | /* MHA Theme Colors */ 2 | /* WCAG AA Compliant Color System (4.5:1+ contrast ratio) */ 3 | /* This file contains ONLY color definitions - no component styling */ 4 | 5 | :root { 6 | /* Brand Colors */ 7 | --primary-blue: #0078d4; 8 | --focus-blue: #005a9e; /* Darker blue for active/focus states - 7.10:1 contrast */ 9 | --hover-blue-link: #0069b9; /* Medium blue for hover - 5.64:1 contrast */ 10 | --white: #ffffff; 11 | --black: #000000; 12 | 13 | /* WCAG AA Compliant Text Colors (4.5:1+ contrast on white) */ 14 | --text-primary: #323130; /* 12.6:1 contrast - primary text */ 15 | --text-secondary: #646464; /* 5.92:1 contrast - secondary text, 4.92:1 on light backgrounds */ 16 | 17 | /* Link Colors - WCAG AA Compliant */ 18 | --link-default: var(--primary-blue); /* Clickable - 4.53:1 contrast */ 19 | --link-active: var(--focus-blue); /* Selected/active - 7.10:1 contrast */ 20 | --link-hover: black; /* Hover state - 21:1 contrast */ 21 | 22 | /* Background Colors */ 23 | --background-gray: #f5f5f5; 24 | --background-light-gray: #eaeaea; 25 | --background-lighter-gray: #f3f2f1; 26 | --background-light-blue: #f5faff; /* Subtle light blue - 12.36:1 with text-primary, 4.31:1 with primary-blue */ 27 | --command-bar-gray: #f4f4f4; 28 | 29 | /* Border Colors */ 30 | --border-gray: #ccc; 31 | --border-light-gray: #e1e1e1; 32 | 33 | /* Dialog Colors - WCAG AA Compliant */ 34 | --dialog-header-bg: var(--primary-blue); /* 4.53:1 contrast for white text */ 35 | --dialog-text-on-primary: var(--white); 36 | 37 | /* Framework7 Overrides - WCAG AA Compliant */ 38 | --text-dark-grey: var(--text-primary); /* was #444 - now 12.6:1 */ 39 | --text-medium-grey: var(--text-secondary); /* was #555555 - now 7.4:1 */ 40 | --text-gray: var(--border-light-gray); /* Keep as border color, not text */ 41 | 42 | /* Interactive States */ 43 | --hover-blue: color-mix(in srgb, var(--primary-blue) 10%, transparent); 44 | --active-blue: color-mix(in srgb, var(--primary-blue) 20%, transparent); 45 | 46 | /* Progress Bar Colors */ 47 | --progress-track-bg: #f3f2f1; /* Light gray background */ 48 | 49 | /* Status Colors */ 50 | --success-green: #107c10; 51 | --success-green-bg: rgba(240, 248, 240, 0.9); 52 | } -------------------------------------------------------------------------------- /src/Scripts/HeaderModel.test.ts: -------------------------------------------------------------------------------- 1 | import { TextDecoder, TextEncoder } from "util"; 2 | 3 | import { HeaderModel } from "./HeaderModel"; 4 | 5 | // Polyfill missing TextEncoder - https://stackoverflow.com/questions/68468203/why-am-i-getting-textencoder-is-not-defined-in-jest 6 | // TODO: Move this to a global setup file 7 | Object.assign(global, { TextDecoder, TextEncoder }); // eslint-disable-line @typescript-eslint/naming-convention 8 | 9 | describe("GetHeaderList Tests", () => { 10 | test("h1", () => { 11 | const headerList = HeaderModel.getHeaderList( 12 | "Subject: =?UTF-8?B?8J+PiCAgMjAxOSdzIE5vLjEgUmVjcnVpdCwgVGhlIFdvcmxkJ3Mg?=\n" + 13 | " =?UTF 8?B?VGFsbGVzdCBUZWVuYWdlciwgVG9wIFBsYXlzIG9mIHRoZSBXZWVrICYgbW9y?=\n" + 14 | " =?UTF-8?B?ZQ==?=\n" + 15 | "Date: Fri, 26 Jan 2018 15:54:11 - 0600\n"); 16 | expect(headerList).toEqual([ 17 | { 18 | "header": "Subject", 19 | "value": "🏈 2019's No.1 Recruit, The World's Tallest Teenager, Top Plays of the Week & more" 20 | }, 21 | { 22 | "header": "Date", 23 | "value": "Fri, 26 Jan 2018 15:54:11 - 0600" 24 | } 25 | ]); 26 | }); 27 | 28 | test("h2", () => { 29 | const headerList = HeaderModel.getHeaderList( 30 | "X-Microsoft-Antispam-Mailbox-Delivery:\n" + 31 | "\tabwl:0;wl:0;pcwl:0;kl:0;iwl:0;ijl:0;dwl:0;dkl:0;rwl:0;ex:0;auth:1;dest:I;ENG:(400001000128)(400125000095)(5062000261)(5061607266)(5061608174)(4900095)(4920089)(6250004)(4950112)(4990090)(400001001318)(400125100095)(61617190)(400001002128)(400125200095);"); 32 | expect(headerList).toEqual([ 33 | { 34 | "header": "X-Microsoft-Antispam-Mailbox-Delivery", 35 | "value": "abwl:0;wl:0;pcwl:0;kl:0;iwl:0;ijl:0;dwl:0;dkl:0;rwl:0;ex:0;auth:1;dest:I;ENG:(400001000128)(400125000095)(5062000261)(5061607266)(5061608174)(4900095)(4920089)(6250004)(4950112)(4990090)(400001001318)(400125100095)(61617190)(400001002128)(400125200095);" 36 | } 37 | ]); 38 | }); 39 | 40 | test("h3", () => { 41 | const headerList = HeaderModel.getHeaderList( 42 | "Content-Type: multipart/alternative;\n" + 43 | "\tboundary=\"ErclWH56b6W5=_?:\""); 44 | expect(headerList).toEqual([ 45 | { 46 | "header": "Content-Type", 47 | "value": "multipart/alternative; boundary=\"ErclWH56b6W5=_?:\"" 48 | } 49 | ]); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/Scripts/row/ForefrontAntispam.ts: -------------------------------------------------------------------------------- 1 | import { mhaStrings } from "../mhaStrings"; 2 | import { AntiSpamReport } from "./Antispam"; 3 | import { Header } from "./Header"; 4 | import { Row } from "./Row"; 5 | 6 | export class ForefrontAntiSpamReport extends AntiSpamReport { 7 | public override readonly tableName: string = "forefrontAntiSpamReport"; 8 | public override readonly displayName: string = mhaStrings.mhaForefrontAntiSpamReport; 9 | public override readonly tag: string = "FFAS"; 10 | private forefrontAntiSpamRows: Row[] = [ 11 | new Row("ARC", mhaStrings.mhaArc, "X-Forefront-Antispam-Report"), 12 | new Row("CTRY", mhaStrings.mhaCountryRegion, "X-Forefront-Antispam-Report"), 13 | new Row("LANG", mhaStrings.mhaLang, "X-Forefront-Antispam-Report"), 14 | new Row("SCL", mhaStrings.mhaScl, "X-MS-Exchange-Organization-SCL"), 15 | new Row("PCL", mhaStrings.mhaPcl, "X-Forefront-Antispam-Report"), 16 | new Row("SFV", mhaStrings.mhaSfv, "X-Forefront-Antispam-Report"), 17 | new Row("IPV", mhaStrings.mhaIpv, "X-Forefront-Antispam-Report"), 18 | new Row("H", mhaStrings.mhaHelo, "X-Forefront-Antispam-Report"), 19 | new Row("PTR", mhaStrings.mhaPtr, "X-Forefront-Antispam-Report"), 20 | new Row("CIP", mhaStrings.mhaCip, "X-Forefront-Antispam-Report"), 21 | new Row("CAT", mhaStrings.mhaCat, "X-Forefront-Antispam-Report"), 22 | new Row("SFTY", mhaStrings.mhaSfty, "X-Forefront-Antispam-Report"), 23 | new Row("SRV", mhaStrings.mhaSrv, "X-Forefront-Antispam-Report"), 24 | new Row("X-CustomSpam", mhaStrings.mhaCustomSpam, "X-Forefront-Antispam-Report"), 25 | new Row("SFS", mhaStrings.mhaSfs, "X-Forefront-Antispam-Report"), 26 | new Row("source", mhaStrings.mhaSource, "X-Forefront-Antispam-Report"), 27 | new Row("unparsed", mhaStrings.mhaUnparsed, "X-Forefront-Antispam-Report") 28 | ]; 29 | 30 | public override add(header: Header): boolean { 31 | if (header.header.toUpperCase() === "X-Forefront-Antispam-Report".toUpperCase()) { 32 | this.parse(header.value); 33 | return true; 34 | } 35 | 36 | return false; 37 | } 38 | 39 | public override get rows(): Row[] { return this.forefrontAntiSpamRows; } 40 | public override toString(): string { 41 | if (!this.exists()) return ""; 42 | const ret = ["ForefrontAntiSpamReport"]; 43 | this.rows.forEach(function (row) { 44 | if (row.value) { ret.push(row.toString()); } 45 | }); 46 | return ret.join("\n"); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Scripts/jestMatchers/stacksEqual.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "@jest/globals"; 2 | import type { MatcherFunction } from "expect"; 3 | 4 | // Strip stack of rows with jest. 5 | // Used to normalize cross environment differences strictly for testing purposes 6 | // Real stacks sent up will contain cross browser quirks 7 | function cleanStack(stack: string[]) { 8 | if (!stack) return null; 9 | return stack.map(function (item: string): string { 10 | return item 11 | .replace(/[A-Z]:\\.*?\\MHA\\/, "") // Remove path prefix that start :\src\MHA 12 | .replace(/MHA\\src/, "src") // Remove path prefix that start MHA\\src 13 | .replace(/[A-Z]:\\.*?\\.*\\src\\/, "src\\") // Remove path prefix that start :\src\MHA 14 | .replace(/Function\.get \[as parse\]/, "Function.parse") // normalize function name 15 | .replace(/.*jest.*/, "") // Don't care about jest internals 16 | .replace(/:\d+:\d*\)/, ")") // remove column and line # since they may vary 17 | ; 18 | }).filter(function (item: string): boolean { 19 | return !!item; 20 | }); 21 | } 22 | 23 | export const stacksEqual: MatcherFunction<[expected: string[]]> = 24 | function (actualUnknown: unknown, expected: string[]) { 25 | const actual = actualUnknown as string[]; 26 | let passed = true; 27 | const messages: string[] = []; 28 | 29 | const actualStack = cleanStack(actual); 30 | const expectedStack = cleanStack(expected); 31 | 32 | if (actualStack === undefined || actualStack === null) { 33 | passed = false; 34 | messages.push("actual is undefined"); 35 | } else if (expectedStack === undefined || expectedStack === null) { 36 | passed = false; 37 | messages.push("expected is undefined"); 38 | } 39 | else { 40 | passed = this.equals(actualStack, expectedStack); 41 | if (!passed) { 42 | messages.push("Stacks do not match"); 43 | messages.push("Actual stack:"); 44 | actualStack.forEach((actualItem) => { messages.push("\t" + actualItem); }); 45 | messages.push("Expected stack:"); 46 | expectedStack.forEach((expectedItem) => { messages.push("\t" + expectedItem); }); 47 | } 48 | } 49 | 50 | return { 51 | pass: passed, 52 | message: () => messages.join("\n"), 53 | }; 54 | }; 55 | 56 | expect.extend({ stacksEqual, }); 57 | 58 | declare module "expect" { 59 | interface Matchers { 60 | stacksEqual(expected: string[]): R; 61 | } 62 | } -------------------------------------------------------------------------------- /src/Scripts/stacks.ts: -------------------------------------------------------------------------------- 1 | import { StackFrame, StackTraceOptions } from "stacktrace-js"; 2 | import * as stackTrace from "stacktrace-js"; 3 | 4 | import { diagnostics } from "./Diag"; 5 | import { Errors } from "./Errors"; 6 | import { Strings } from "./Strings"; 7 | 8 | export class Stack { 9 | public static options: StackTraceOptions = {offline: false, filter: Stack.filterStack}; 10 | 11 | // While trying to get our error tracking under control, let's not filter our stacks 12 | private static filterStack(item: StackFrame):boolean { 13 | if (!item.fileName) return true; 14 | if (item.fileName.indexOf("stacktrace") !== -1) return false; // remove stacktrace.js frames 15 | if (item.fileName.indexOf("stacks.ts") !== -1) return false; // remove stacks.ts frames 16 | //if (item.functionName === "ShowError") return false; 17 | //if (item.functionName === "showError") return false; 18 | //if (item.functionName === "Errors.log") return false; // Logs with Errors.log in them usually have location where it was called from - keep those 19 | //if (item.functionName === "GetStack") return false; 20 | if (item.functionName === "Errors.isError") return false; // Not called from anywhere interesting 21 | if (item.functionName?.indexOf("Promise._immediateFn") !== -1) return false; // only shows in IE stacks 22 | return true; 23 | } 24 | 25 | private static async getExceptionStack(exception: unknown): Promise { 26 | let stack; 27 | if (!Errors.isError(exception)) { 28 | stack = await stackTrace.get(Stack.options); 29 | } else { 30 | stack = await stackTrace.fromError(exception as Error, Stack.options); 31 | } 32 | 33 | return stack; 34 | } 35 | 36 | public static parse(exception: unknown, message: string | null, handler: (eventName: string, stack: string[]) => void): void { 37 | let stack; 38 | const exceptionMessage = Errors.getErrorMessage(exception); 39 | 40 | let eventName = Strings.joinArray([message, exceptionMessage], " : "); 41 | if (!eventName) { 42 | eventName = "Unknown exception"; 43 | } 44 | 45 | this.getExceptionStack(exception).then((stackframes) => { 46 | stack = stackframes.map(function (sf) { 47 | return sf.toString(); 48 | }); 49 | handler(eventName, stack); 50 | }).catch((err) => { 51 | diagnostics.trackEvent({ name: "Errors.parse errback" }); 52 | stack = [JSON.stringify(exception, null, 2), "Parsing error:", JSON.stringify(err, null, 2)]; 53 | handler(eventName, stack); 54 | }); 55 | } 56 | } -------------------------------------------------------------------------------- /src/Scripts/MHADates.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import localizedFormat from "dayjs/plugin/localizedFormat"; 3 | 4 | import { DateWithNum } from "./DateWithNum"; 5 | 6 | export class MHADates { 7 | static { 8 | dayjs.extend(localizedFormat); 9 | } 10 | 11 | // parse date using dayjs, with fallback to browser based parsing 12 | public static parseDate(date: string): DateWithNum { 13 | // Cross browser dates - ugh! 14 | // http://dygraphs.com/date-formats.html 15 | 16 | // Invert any backwards dates: 2018-01-28 -> 01-28-2018 17 | // dayjs can handle these, but inverting manually makes it easier for the dash replacement 18 | date = date.replace(/\s*(\d{4})-(\d{1,2})-(\d{1,2})/g, "$2/$3/$1"); 19 | // Replace dashes with slashes 20 | date = date.replace(/\s*(\d{1,2})-(\d{1,2})-(\d{4})/g, "$1/$2/$3"); 21 | 22 | // If we don't have a +xxxx or -xxxx on our date, it will be interpreted in local time 23 | // This likely isn't the intended timezone, so we add a +0000 to get UTC 24 | const offset: RegExpMatchArray | null = date.match(/[+|-]\d{4}/); 25 | const originalDate: string = date; 26 | let offsetAdded = false; 27 | if (!offset || offset.length !== 1) { 28 | date += " +0000"; 29 | offsetAdded = true; 30 | } 31 | 32 | // Some browsers (firefox) don't like milliseconds in dates, and dayjs doesn't hide that from us 33 | // Trim off milliseconds so we don't pass them into dayjs 34 | const milliseconds: RegExpMatchArray | null = date.match(/\d{1,2}:\d{2}:\d{2}.(\d+)/); 35 | date = date.replace(/(\d{1,2}:\d{2}:\d{2}).(\d+)/, "$1"); 36 | 37 | if (dayjs) { 38 | // And now we can parse our date 39 | let time: dayjs.Dayjs = dayjs(date); 40 | 41 | // If adding offset didn't work, try adding time and offset 42 | if (!time.isValid() && offsetAdded) { time = dayjs(originalDate + " 12:00:00 AM +0000"); } 43 | if (milliseconds && milliseconds.length >= 2) { 44 | time = time.add(Math.floor(parseFloat("0." + milliseconds[1]) * 1000), "ms"); 45 | } 46 | 47 | return new DateWithNum( 48 | time.valueOf(), 49 | time.format("l LTS")); 50 | } 51 | else { 52 | let dateNum = Date.parse(date); 53 | if (milliseconds && milliseconds.length >= 2) { 54 | dateNum = dateNum + Math.floor(parseFloat("0." + milliseconds[1]) * 1000); 55 | } 56 | 57 | return new DateWithNum( 58 | dateNum, 59 | new Date(dateNum).toLocaleString().replace(/\u200E|,/g, "")); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Scripts/Summary.ts: -------------------------------------------------------------------------------- 1 | import { mhaStrings } from "./mhaStrings"; 2 | import { ArchivedRow } from "./row/ArchivedRow"; 3 | import { CreationRow } from "./row/CreationRow"; 4 | import { Header } from "./row/Header"; 5 | import { Row } from "./row/Row"; 6 | import { SummaryRow } from "./row/SummaryRow"; 7 | import { SummaryTable } from "./table/SummaryTable"; 8 | 9 | export class Summary extends SummaryTable { 10 | public readonly tableName: string = "summary"; 11 | public readonly displayName: string = mhaStrings.mhaSummary; 12 | public readonly tag: string = "SUM"; 13 | private totalTimeInternal = ""; 14 | 15 | private creationPostFix(totalTime: string): string { 16 | if (!totalTime) { 17 | return ""; 18 | } 19 | 20 | return ` ${mhaStrings.mhaDeliveredStart} ${totalTime}${mhaStrings.mhaDeliveredEnd}`; 21 | } 22 | 23 | private dateRow = new CreationRow("Date", mhaStrings.mhaCreationTime); 24 | 25 | private archivedRow = new ArchivedRow("Archived-At", mhaStrings.mhaArchivedAt,); 26 | 27 | private summaryRows: SummaryRow[] = [ 28 | new SummaryRow("Subject", mhaStrings.mhaSubject), 29 | new SummaryRow("Message-ID", mhaStrings.mhaMessageId), 30 | this.archivedRow, 31 | this.dateRow, 32 | new SummaryRow("From", mhaStrings.mhaFrom), 33 | new SummaryRow("Reply-To", mhaStrings.mhaReplyTo), 34 | new SummaryRow("To", mhaStrings.mhaTo), 35 | new SummaryRow("CC", mhaStrings.mhaCc) 36 | ]; 37 | 38 | public override exists(): boolean { 39 | let row: Row | undefined; 40 | this.rows.forEach((r: Row) => { if (!row && r.value) row = r; }); 41 | return row !== undefined; 42 | } 43 | 44 | public add(header: Header) { 45 | if (!header) { 46 | return false; 47 | } 48 | 49 | let row: SummaryRow | undefined; 50 | this.rows.forEach((r: Row) => { if (!row && r.header.toUpperCase() === header.header.toUpperCase()) row = r; }); 51 | if (row) { 52 | row.value = header.value; 53 | return true; 54 | } 55 | 56 | return false; 57 | } 58 | 59 | public get rows(): SummaryRow[] { return this.summaryRows; } 60 | public get totalTime(): string { return this.totalTimeInternal; } 61 | public set totalTime(value: string) { 62 | this.totalTimeInternal = value; 63 | this.dateRow.postFix = this.creationPostFix(value); 64 | } 65 | 66 | public override toString(): string { 67 | if (!this.exists()) return ""; 68 | const ret = ["Summary"]; 69 | this.rows.forEach(function (row) { 70 | if (row.value) { ret.push(row.toString()); } 71 | }); 72 | return ret.join("\n"); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Pages/mha.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Message Header Analyzer 7 | 8 | 9 | 10 | 11 |
12 |
13 |

Message Header Analyzer

14 |
15 |
16 |
17 |

Input Headers

18 | 20 | 21 | Analyze headers 22 |
23 |
24 |
25 |
26 |
27 | Analyze message headers 28 | 29 | Clear 30 | 31 | 32 | 33 | Copy 34 | Copy analysis results to clipboard 35 |
36 |
37 |
38 | 41 |
42 | 43 |
44 | 45 |
46 |

Analysis Results

47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | -------------------------------------------------------------------------------- /src/Scripts/jestMatchers/receivedEqual.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "@jest/globals"; 2 | import type { MatcherFunction } from "expect"; 3 | 4 | import { ReceivedRow } from "../row/ReceivedRow"; 5 | 6 | export const receivedEqual: MatcherFunction<[expected: { [index: string]: string | number | null }]> = 7 | function (actualUnknown: unknown, expected: { [index: string]: string | number | null }) { 8 | const actual = actualUnknown as ReceivedRow; 9 | let passed = true; 10 | const messages: string[] = []; 11 | 12 | try { 13 | if (typeof actual !== "object" || actual == null) { 14 | return { 15 | message: () => "Actual is not an object", 16 | pass: false, 17 | }; 18 | } 19 | 20 | if (typeof expected !== "object" || expected == null) { 21 | return { 22 | message: () => "Expected is not an object", 23 | pass: false, 24 | }; 25 | } 26 | 27 | for (const [field, value] of Object.entries(expected)) { 28 | if (field === "date") continue; 29 | if (field === "postFix") continue; 30 | if (field === "valueInternal") continue; 31 | if (value === null && actual[field]?.toString() === "null") continue; 32 | if (typeof value === "number" && value.toString() === actual[field]?.toString()) continue; 33 | if (typeof value === "string" && value === actual[field]?.toString()) continue; 34 | 35 | messages.push("actual: " + field + " = " + actual[field]); 36 | messages.push("expected: " + field + " = " + value); 37 | passed = false; 38 | } 39 | 40 | for (const field in actual) { 41 | if (field === "date") continue; 42 | if (field === "onGetUrl") continue; 43 | if (field === "setField") continue; 44 | if (field === "postFix") continue; 45 | if (field === "valueInternal") continue; 46 | // If a field in value is non-null/empty there must also be a field in expected 47 | if (actual[field] && actual[field].toString() && expected[field] === undefined) { 48 | messages.push("actual: " + field + " = " + actual[field]); 49 | messages.push("expected: " + field + " = " + expected[field]); 50 | passed = false; 51 | } 52 | } 53 | } 54 | catch (e: unknown) { 55 | console.log(e); 56 | } 57 | 58 | if (messages.length === 0) { messages.push("Received rows are equal"); } 59 | 60 | return { 61 | message: () => messages.join("\n"), 62 | pass: passed 63 | }; 64 | }; 65 | 66 | expect.extend({ receivedEqual, }); 67 | 68 | declare module "expect" { 69 | interface Matchers { 70 | receivedEqual(expected: { [index: string]: string | number | null }): R; 71 | } 72 | } -------------------------------------------------------------------------------- /src/Scripts/mhaStrings.ts: -------------------------------------------------------------------------------- 1 | export const mhaStrings = { 2 | // REST 3 | mhaLoading: "Loading...", 4 | mhaRequestSent: "Retrieving headers from server.", 5 | mhaFoundHeaders: "Found headers", 6 | mhaProcessingHeader: "Processing header", 7 | mhaHeadersMissing: "Message was missing transport headers. If this is a sent item this may be expected.", 8 | mhaMessageMissing: "Message not located.", 9 | mhaRequestFailed: "Failed to retrieve headers.", 10 | 11 | // Status Messages for Actions 12 | mhaAnalyzed: "Headers analyzed successfully", 13 | mhaCleared: "Headers cleared", 14 | mhaCopied: "Copied to clipboard!", 15 | mhaNoHeaders: "Please enter headers to analyze", 16 | mhaNothingToCopy: "No analysis results to copy", 17 | 18 | // Headers 19 | mhaNegative: "-", 20 | mhaMinute: "minute", 21 | mhaMinutes: "minutes", 22 | mhaSecond: "second", 23 | mhaSeconds: "seconds", 24 | mhaSummary: "Summary", 25 | mhaPrompt: "Insert the message header you would like to analyze", 26 | mhaReceivedHeaders: "Received headers", 27 | mhaForefrontAntiSpamReport: "Forefront Antispam Report Header", 28 | mhaAntiSpamReport: "Microsoft Antispam Header", 29 | mhaOtherHeaders: "Other headers", 30 | mhaOriginalHeaders: "Original headers", 31 | mhaDeliveredStart: "(Delivered after", 32 | mhaDeliveredEnd: ")", 33 | mhaParsingHeaders: "Parsing headers to tables", 34 | mhaProcessingReceivedHeader: "Processing received header ", 35 | 36 | // Summary 37 | mhaSubject: "Subject", 38 | mhaMessageId: "Message Id", 39 | mhaCreationTime: "Creation time", 40 | mhaFrom: "From", 41 | mhaReplyTo: "Reply to", 42 | mhaTo: "To", 43 | mhaCc: "Cc", 44 | mhaArchivedAt: "Archived at", 45 | 46 | // Received 47 | mhaReceivedHop: "Hop", 48 | mhaReceivedSubmittingHost: "Submitting host", 49 | mhaReceivedReceivingHost: "Receiving host", 50 | mhaReceivedTime: "Time", 51 | mhaReceivedDelay: "Delay", 52 | mhaReceivedType: "Type", 53 | mhaReceivedFrom: "From", 54 | mhaReceivedBy: "By", 55 | mhaReceivedWith: "With", 56 | mhaReceivedId: "Id", 57 | mhaReceivedFor: "For", 58 | mhaReceivedVia: "Via", 59 | mhaReceivedDate: "Date", 60 | mhaReceivedPercent: "Percent", 61 | 62 | // Other 63 | mhaNumber: "#", 64 | mhaHeader: "Header", 65 | mhaValue: "Value", 66 | 67 | // ForefrontAntiSpamReport 68 | mhaSource: "Source header", 69 | mhaUnparsed: "Unknown fields", 70 | mhaArc: "ARC protocol", 71 | mhaCountryRegion: "Country/Region", 72 | mhaLang: "Language", 73 | mhaScl: "Spam Confidence Level", 74 | mhaSfv: "Spam Filtering Verdict", 75 | mhaPcl: "Phishing Confidence Level", 76 | mhaIpv: "IP Filter Verdict", 77 | mhaHelo: "HELO/EHLO String", 78 | mhaPtr: "PTR Record", 79 | mhaCip: "Connecting IP Address", 80 | mhaCat: "Protection Policy Category", 81 | mhaSfty: "Phishing message", 82 | mhaSrv: "Bulk email status", 83 | mhaCustomSpam: "Advanced Spam Filtering", 84 | mhaSfs: "Spam rules", 85 | 86 | // AntiSpamReport 87 | mhaBcl: "Bulk Complaint Level" 88 | }; 89 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["main"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["main"] 20 | merge_group: 21 | schedule: 22 | - cron: "0 0 * * 1" 23 | 24 | permissions: 25 | contents: read 26 | 27 | jobs: 28 | analyze: 29 | name: Analyze 30 | runs-on: ubuntu-latest 31 | permissions: 32 | actions: read 33 | contents: read 34 | security-events: write 35 | 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | language: ["javascript", "typescript"] 40 | # CodeQL supports [ $supported-codeql-languages ] 41 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 42 | 43 | steps: 44 | - name: Harden Runner 45 | uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 46 | with: 47 | egress-policy: audit 48 | 49 | - name: Checkout repository 50 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 51 | 52 | # Initializes the CodeQL tools for scanning. 53 | - name: Initialize CodeQL 54 | uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5 55 | with: 56 | languages: ${{ matrix.language }} 57 | # If you wish to specify custom queries, you can do so here or in a config file. 58 | # By default, queries listed here will override any specified in a config file. 59 | # Prefix the list here with "+" to use these queries and those in the config file. 60 | 61 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 62 | # If this step fails, then you should remove it and run the build manually (see below) 63 | - name: Autobuild 64 | uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5 65 | 66 | # ℹ️ Command-line programs to run using the OS shell. 67 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 68 | 69 | # If the Autobuild fails above, remove it and uncomment the following three lines. 70 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 71 | 72 | # - run: | 73 | # echo "Run, Build Application using script" 74 | # ./location_of_script_within_repo/buildscript.sh 75 | 76 | - name: Perform CodeQL Analysis 77 | uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5 78 | with: 79 | category: "/language:${{matrix.language}}" 80 | -------------------------------------------------------------------------------- /.github/workflows/scorecards.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: '20 7 * * 2' 14 | push: 15 | branches: ["main"] 16 | 17 | # Declare default permissions as read only. 18 | permissions: read-all 19 | 20 | jobs: 21 | analysis: 22 | name: Scorecard analysis 23 | runs-on: ubuntu-latest 24 | permissions: 25 | # Needed to upload the results to code-scanning dashboard. 26 | security-events: write 27 | # Needed to publish results and get a badge (see publish_results below). 28 | id-token: write 29 | contents: read 30 | actions: read 31 | 32 | steps: 33 | - name: Harden Runner 34 | uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 35 | with: 36 | egress-policy: audit 37 | 38 | - name: "Checkout code" 39 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 40 | with: 41 | persist-credentials: false 42 | 43 | - name: "Run analysis" 44 | uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 45 | with: 46 | results_file: results.sarif 47 | results_format: sarif 48 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 49 | # - you want to enable the Branch-Protection check on a *public* repository, or 50 | # - you are installing Scorecards on a *private* repository 51 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. 52 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 53 | 54 | # Public repositories: 55 | # - Publish results to OpenSSF REST API for easy access by consumers 56 | # - Allows the repository to include the Scorecard badge. 57 | # - See https://github.com/ossf/scorecard-action#publishing-results. 58 | # For private repositories: 59 | # - `publish_results` will always be set to `false`, regardless 60 | # of the value entered here. 61 | publish_results: true 62 | 63 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 64 | # format to the repository Actions tab. 65 | - name: "Upload artifact" 66 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 67 | with: 68 | name: SARIF file 69 | path: results.sarif 70 | retention-days: 5 71 | 72 | # Upload the results to GitHub's code scanning dashboard. 73 | - name: "Upload to code-scanning" 74 | uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5 75 | with: 76 | sarif_file: results.sarif 77 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Build (Development)", 8 | "type": "npm", 9 | "script": "build:dev", 10 | "dependsOn": [ 11 | "Install" 12 | ], 13 | "group": { 14 | "kind": "build", 15 | "isDefault": true 16 | }, 17 | "presentation": { 18 | "clear": true, 19 | "panel": "shared", 20 | "showReuseMessage": false 21 | } 22 | }, 23 | { 24 | "label": "Build (Production)", 25 | "type": "npm", 26 | "script": "build", 27 | "dependsOn": [ 28 | "Install" 29 | ], 30 | "group": "build", 31 | "presentation": { 32 | "clear": true, 33 | "panel": "shared", 34 | "showReuseMessage": false 35 | } 36 | }, 37 | { 38 | "label": "Debug: Outlook Desktop", 39 | "type": "shell", 40 | "command": "npm", 41 | "args": ["run", "start:desktop", "--", "--app", "Outlook"], 42 | "presentation": { 43 | "clear": true, 44 | "panel": "dedicated" 45 | }, 46 | "problemMatcher": [] 47 | }, 48 | { 49 | "label": "Dev Server", 50 | "type": "npm", 51 | "script": "dev-server", 52 | "presentation": { 53 | "clear": true, 54 | "panel": "dedicated" 55 | }, 56 | "problemMatcher": [] 57 | }, 58 | { 59 | "label": "Install", 60 | "type": "npm", 61 | "script": "install", 62 | "presentation": { 63 | "clear": true, 64 | "panel": "shared", 65 | "showReuseMessage": false 66 | }, 67 | "problemMatcher": [] 68 | }, 69 | { 70 | "label": "Lint: Check for problems", 71 | "type": "npm", 72 | "script": "lint", 73 | "problemMatcher": [ 74 | "$eslint-stylish" 75 | ] 76 | }, 77 | { 78 | "label": "Lint: Fix all auto-fixable problems", 79 | "type": "npm", 80 | "script": "lint:fix", 81 | "problemMatcher": [ 82 | "$eslint-stylish" 83 | ] 84 | }, 85 | { 86 | "label": "Stop Debug", 87 | "type": "npm", 88 | "script": "stop", 89 | "presentation": { 90 | "clear": true, 91 | "panel": "shared", 92 | "showReuseMessage": false 93 | }, 94 | "problemMatcher": [] 95 | }, 96 | { 97 | "label": "Watch", 98 | "type": "npm", 99 | "script": "watch", 100 | "presentation": { 101 | "clear": true, 102 | "panel": "dedicated" 103 | }, 104 | "problemMatcher": [] 105 | }, 106 | { 107 | "label": "Check OS", 108 | "type": "shell", 109 | "windows": { 110 | "command": "echo 'Sideloading in Outlook on Windows is supported'" 111 | }, 112 | "linux": { 113 | "command": "echo 'Sideloading on Linux is not supported' && exit 1" 114 | }, 115 | "osx": { 116 | "command": "echo 'Sideloading in Outlook on Mac is not supported' && exit 1" 117 | }, 118 | "presentation": { 119 | "clear": true, 120 | "panel": "dedicated" 121 | }, 122 | } 123 | ], 124 | } 125 | -------------------------------------------------------------------------------- /src/Scripts/ui/classicDesktopFrame.ts: -------------------------------------------------------------------------------- 1 | import "../../Content/Office.css"; 2 | import "../../Content/classicDesktopFrame.css"; 3 | 4 | import { HeaderModel } from "../HeaderModel"; 5 | import { mhaStrings } from "../mhaStrings"; 6 | import { Poster } from "../Poster"; 7 | import { DomUtils } from "./domUtils"; 8 | import { Table } from "./Table"; 9 | 10 | // This is the "classic" UI rendered in classicDesktopFrame.html 11 | 12 | let viewModel: HeaderModel | null = null; 13 | let table: Table; 14 | 15 | function postError(error: unknown, message: string): void { 16 | Poster.postMessageToParent("LogError", { error: JSON.stringify(error), message: message }); 17 | } 18 | 19 | function enableSpinner(): void { 20 | const responseElement = document.getElementById("response"); 21 | if (responseElement) { 22 | responseElement.style.backgroundImage = "url(../Resources/loader.gif)"; 23 | responseElement.style.backgroundRepeat = "no-repeat"; 24 | responseElement.style.backgroundPosition = "center"; 25 | } 26 | } 27 | 28 | function disableSpinner(): void { 29 | const responseElement = document.getElementById("response"); 30 | if (responseElement) { 31 | responseElement.style.background = "none"; 32 | } 33 | } 34 | 35 | function updateStatus(statusText: string): void { 36 | enableSpinner(); 37 | DomUtils.setText("#status", statusText); 38 | if (viewModel !== null) { 39 | viewModel.status = statusText; 40 | } 41 | 42 | table.recalculateVisibility(); 43 | } 44 | 45 | async function renderItem(headers: string) { 46 | updateStatus(mhaStrings.mhaFoundHeaders); 47 | DomUtils.setText("#originalHeaders", headers); 48 | viewModel = await HeaderModel.create(headers); 49 | table.initializeTableUI(viewModel); 50 | updateStatus(""); 51 | disableSpinner(); 52 | } 53 | 54 | // Handles rendering of an error. 55 | // Does not log the error - caller is responsible for calling PostError 56 | function showError(error: unknown, message: string) { 57 | console.error("Error:", error); 58 | updateStatus(message); 59 | disableSpinner(); 60 | table.rebuildSections(viewModel); 61 | } 62 | 63 | function eventListener(event: MessageEvent): void { 64 | if (!event || event.origin !== Poster.site()) return; 65 | 66 | if (event.data) { 67 | switch (event.data.eventName) { 68 | case "showError": 69 | showError(JSON.parse(event.data.data.error), event.data.data.message); 70 | break; 71 | case "updateStatus": 72 | updateStatus(event.data.data); 73 | break; 74 | case "renderItem": 75 | renderItem(event.data.data); 76 | break; 77 | } 78 | } 79 | } 80 | 81 | // This function is run when the app is ready to start interacting with the host application. 82 | // It ensures the DOM is ready before updating the span elements with values from the current message. 83 | document.addEventListener("DOMContentLoaded", function() { 84 | try { 85 | table = new Table(); 86 | table.initializeTableUI(); 87 | updateStatus(mhaStrings.mhaLoading); 88 | window.addEventListener("message", eventListener, false); 89 | Poster.postMessageToParent("frameActive"); 90 | } 91 | catch (e) { 92 | postError(e, "Failed initializing frame"); 93 | showError(e, "Failed initializing frame"); 94 | } 95 | }); 96 | -------------------------------------------------------------------------------- /src/Scripts/table/TableSection.test.ts: -------------------------------------------------------------------------------- 1 | import { TableSection } from "./TableSection"; 2 | 3 | describe("TableSection", () => { 4 | // Concrete implementation for testing abstract base class 5 | class TestTableSection extends TableSection { 6 | public readonly tableName = "testTable"; 7 | public readonly displayName = "Test Table"; 8 | public readonly paneClass = "sectionHeader" as const; 9 | 10 | private testExists = true; 11 | private testToString = "Test table content"; 12 | 13 | public exists(): boolean { 14 | return this.testExists; 15 | } 16 | 17 | public toString(): string { 18 | return this.testToString; 19 | } 20 | 21 | // Test helpers 22 | public setExists(value: boolean): void { 23 | this.testExists = value; 24 | } 25 | 26 | public setToString(value: string): void { 27 | this.testToString = value; 28 | } 29 | } 30 | 31 | let tableSection: TestTableSection; 32 | 33 | beforeEach(() => { 34 | tableSection = new TestTableSection(); 35 | }); 36 | 37 | describe("Abstract properties", () => { 38 | it("should have correct table name", () => { 39 | expect(tableSection.tableName).toBe("testTable"); 40 | }); 41 | 42 | it("should have correct display name", () => { 43 | expect(tableSection.displayName).toBe("Test Table"); 44 | }); 45 | 46 | it("should have correct pane class", () => { 47 | expect(tableSection.paneClass).toBe("sectionHeader"); 48 | }); 49 | }); 50 | 51 | describe("Abstract methods", () => { 52 | it("should implement exists method", () => { 53 | expect(tableSection.exists()).toBe(true); 54 | 55 | tableSection.setExists(false); 56 | expect(tableSection.exists()).toBe(false); 57 | }); 58 | 59 | it("should implement toString method", () => { 60 | expect(tableSection.toString()).toBe("Test table content"); 61 | 62 | tableSection.setToString("Modified content"); 63 | expect(tableSection.toString()).toBe("Modified content"); 64 | }); 65 | }); 66 | 67 | describe("Accessibility methods", () => { 68 | it("should return display name as table caption", () => { 69 | expect(tableSection.getTableCaption()).toBe("Test Table"); 70 | }); 71 | 72 | it("should return proper ARIA label", () => { 73 | expect(tableSection.getAriaLabel()).toBe("Test Table table"); 74 | }); 75 | 76 | it("should handle different display names in accessibility methods", () => { 77 | // Test with the existing test table's display name 78 | expect(tableSection.getTableCaption()).toBe(tableSection.displayName); 79 | expect(tableSection.getAriaLabel()).toBe(`${tableSection.displayName} table`); 80 | }); 81 | }); 82 | 83 | describe("Type checking", () => { 84 | it("should be instance of TableSection", () => { 85 | expect(tableSection).toBeInstanceOf(TableSection); 86 | }); 87 | 88 | it("should allow polymorphic usage", () => { 89 | const baseRef: TableSection = tableSection; 90 | expect(baseRef.getTableCaption()).toBe("Test Table"); 91 | expect(baseRef.getAriaLabel()).toBe("Test Table table"); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /.vsconfig: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "components": [ 4 | "Microsoft.VisualStudio.Component.CoreEditor", 5 | "Microsoft.VisualStudio.Workload.CoreEditor", 6 | "Microsoft.NetCore.Component.Runtime.5.0", 7 | "Microsoft.NetCore.Component.Runtime.3.1", 8 | "Microsoft.NetCore.Component.SDK", 9 | "Microsoft.VisualStudio.Component.NuGet", 10 | "Microsoft.Net.Component.4.6.1.TargetingPack", 11 | "Microsoft.VisualStudio.Component.Roslyn.Compiler", 12 | "Microsoft.VisualStudio.Component.Roslyn.LanguageServices", 13 | "Microsoft.VisualStudio.Component.FSharp", 14 | "Microsoft.ComponentGroup.ClickOnce.Publish", 15 | "Microsoft.NetCore.Component.DevelopmentTools", 16 | "Microsoft.VisualStudio.Component.FSharp.WebTemplates", 17 | "Microsoft.VisualStudio.ComponentGroup.WebToolsExtensions", 18 | "Microsoft.VisualStudio.Component.DockerTools", 19 | "Microsoft.NetCore.Component.Web", 20 | "Microsoft.Net.Component.4.8.SDK", 21 | "Microsoft.Net.Component.4.7.2.TargetingPack", 22 | "Microsoft.Net.ComponentGroup.DevelopmentPrerequisites", 23 | "Microsoft.VisualStudio.Component.TypeScript.4.1", 24 | "Microsoft.VisualStudio.Component.JavaScript.TypeScript", 25 | "Microsoft.VisualStudio.Component.JavaScript.Diagnostics", 26 | "Microsoft.Component.MSBuild", 27 | "Microsoft.VisualStudio.Component.TextTemplating", 28 | "Component.Microsoft.VisualStudio.RazorExtension", 29 | "Microsoft.VisualStudio.Component.IISExpress", 30 | "Microsoft.VisualStudio.Component.SQL.ADAL", 31 | "Microsoft.VisualStudio.Component.SQL.LocalDB.Runtime", 32 | "Microsoft.VisualStudio.Component.Common.Azure.Tools", 33 | "Microsoft.VisualStudio.Component.SQL.CLR", 34 | "Microsoft.VisualStudio.Component.MSODBC.SQL", 35 | "Microsoft.VisualStudio.Component.MSSQL.CMDLnUtils", 36 | "Microsoft.VisualStudio.Component.ManagedDesktop.Core", 37 | "Microsoft.Net.Component.4.5.2.TargetingPack", 38 | "Microsoft.Net.Component.4.5.TargetingPack", 39 | "Microsoft.VisualStudio.Component.SQL.SSDT", 40 | "Microsoft.VisualStudio.Component.SQL.DataSources", 41 | "Component.Microsoft.Web.LibraryManager", 42 | "Microsoft.VisualStudio.ComponentGroup.Web", 43 | "Microsoft.Net.Component.4.TargetingPack", 44 | "Microsoft.Net.Component.4.5.1.TargetingPack", 45 | "Microsoft.Net.Component.4.6.TargetingPack", 46 | "Microsoft.Net.ComponentGroup.TargetingPacks.Common", 47 | "Microsoft.Net.Core.Component.SDK.2.1", 48 | "Microsoft.VisualStudio.Component.Azure.AuthoringTools", 49 | "Microsoft.VisualStudio.Component.IntelliTrace.FrontEnd", 50 | "Microsoft.VisualStudio.Component.DiagnosticTools", 51 | "Microsoft.VisualStudio.Component.EntityFramework", 52 | "Microsoft.VisualStudio.Component.LiveUnitTesting", 53 | "Microsoft.VisualStudio.Component.AppInsights.Tools", 54 | "Microsoft.VisualStudio.Component.Debugger.JustInTime", 55 | "Microsoft.VisualStudio.Component.IntelliCode", 56 | "Microsoft.VisualStudio.Component.ClassDesigner", 57 | "Microsoft.VisualStudio.Component.GraphDocument", 58 | "Microsoft.VisualStudio.Component.CodeMap", 59 | "Microsoft.VisualStudio.Component.VC.CoreIde", 60 | "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", 61 | "Microsoft.VisualStudio.Component.Graphics.Tools", 62 | "Microsoft.VisualStudio.Component.VC.DiagnosticTools", 63 | "Microsoft.VisualStudio.Component.VC.Redist.14.Latest", 64 | "Microsoft.VisualStudio.ComponentGroup.ArchitectureTools.Native", 65 | "Microsoft.VisualStudio.ComponentGroup.NativeDesktop.Core", 66 | "Microsoft.VisualStudio.Component.VC.ATL", 67 | "Microsoft.VisualStudio.Component.VC.ATLMFC", 68 | "Microsoft.VisualStudio.Component.Windows10SDK.18362", 69 | "Microsoft.VisualStudio.Workload.NativeDesktop" 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /src/Scripts/utils/ParentFrameUtils.ts: -------------------------------------------------------------------------------- 1 | import { Choice } from "../Choice"; 2 | import { diagnostics } from "../Diag"; 3 | import { Errors } from "../Errors"; 4 | 5 | /** 6 | * Utility functions extracted from ParentFrame for better testability 7 | */ 8 | export class ParentFrameUtils { 9 | /** 10 | * Parses a query string parameter from the current URL 11 | * @param variable The parameter name to look for 12 | * @returns The parameter value or empty string if not found 13 | */ 14 | static getQueryVariable(variable: string, search: string = window.location.search): string { 15 | const vars: string[] = search.substring(1).split("&"); 16 | 17 | let found = ""; 18 | vars.forEach((v: string) => { 19 | if (found === "") { 20 | const pair: string[] = v.split("="); 21 | if (pair[0] === variable) { 22 | found = pair[1] ?? ""; 23 | } 24 | } 25 | }); 26 | 27 | return found; 28 | } 29 | 30 | /** 31 | * Generates a settings key based on the Office host name 32 | * @returns Settings key string 33 | */ 34 | static getSettingsKey(): string { 35 | try { 36 | return "frame" + Office.context.mailbox.diagnostics.hostName; 37 | } catch { 38 | return "frame"; 39 | } 40 | } 41 | 42 | /** 43 | * Sets the default choice based on query parameter or fallback 44 | * @param choices Array of available choices 45 | * @param defaultLabel Default choice label if no query parameter 46 | * @param search Query string to parse (defaults to window.location.search) 47 | * @returns Updated choices array with one marked as checked 48 | */ 49 | static setDefaultChoice(choices: Choice[], defaultLabel = "new", search: string = window.location.search): Choice[] { 50 | let uiDefault: string = ParentFrameUtils.getQueryVariable("default", search); 51 | if (!uiDefault) { 52 | uiDefault = defaultLabel; 53 | } 54 | 55 | return choices.map((choice: Choice) => ({ 56 | ...choice, 57 | checked: uiDefault === choice.label 58 | })); 59 | } 60 | 61 | /** 62 | * Generates diagnostics string from current diagnostic data and errors 63 | * @returns Formatted diagnostics string 64 | */ 65 | static getDiagnosticsString(): string { 66 | let diagnosticsString = ""; 67 | 68 | try { 69 | const diagnosticMap = diagnostics.get(); 70 | for (const diag in diagnosticMap) { 71 | if (Object.prototype.hasOwnProperty.call(diagnosticMap, diag)) { 72 | diagnosticsString += diag + " = " + diagnosticMap[diag] + "\n"; 73 | } 74 | } 75 | } catch { 76 | diagnosticsString += "ERROR: Failed to get diagnostics\n"; 77 | } 78 | 79 | const errors: string[] = Errors.get(); 80 | errors.forEach((error: string) => { 81 | diagnosticsString += "ERROR: " + error + "\n"; 82 | }); 83 | 84 | return diagnosticsString; 85 | } 86 | 87 | /** 88 | * Validates a choice object 89 | * @param choice The choice to validate 90 | * @returns True if the choice is valid 91 | */ 92 | static isValidChoice(choice: unknown): choice is Choice { 93 | return choice !== null && 94 | choice !== undefined && 95 | typeof choice === "object" && 96 | typeof (choice as Choice).label === "string" && 97 | typeof (choice as Choice).url === "string" && 98 | typeof (choice as Choice).checked === "boolean"; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Pages/uitoggle.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Message Header Analyzer 9 | 10 | 11 | 12 |
13 |
14 | 22 |
23 | 24 | 25 |
26 | Copied to clipboard! 27 |
28 | 29 |
30 | 31 |
32 | 33 | 34 | 57 | 58 | 59 | 72 |
73 | 74 | 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mha", 3 | "version": "1.0.0", 4 | "description": "Message Header Analyzer", 5 | "type": "module", 6 | "engines": { 7 | "node": ">=18.12.0", 8 | "npm": ">=8.19.2" 9 | }, 10 | "private": true, 11 | "scripts": { 12 | "clean": "node tasks/clean.js", 13 | "watch": "webpack --watch --mode=development", 14 | "build": "webpack --mode=production --node-env=production", 15 | "build:dev": "webpack --mode=development", 16 | "build:analyze": "webpack --mode=production --env analyze", 17 | "serve": "webpack serve --mode=development", 18 | "dev-server": "webpack serve --mode=development", 19 | "start": "office-addin-debugging start ManifestDebugLocal.xml", 20 | "start:debug-server": "office-addin-debugging start manifestDebugServer.xml", 21 | "start:desktop": "office-addin-debugging start ManifestDebugLocal.xml desktop --app", 22 | "stop": "office-addin-debugging stop ManifestDebugLocal.xml && office-addin-debugging stop manifestDebugServer.xml", 23 | "validate": "office-addin-manifest validate Manifest.xml", 24 | "test": "jest --silent", 25 | "test:debug": "jest --verbose", 26 | "lint": "eslint \"*.{js,ts}\" \"src/**/*\" \"tasks/**/*\"", 27 | "lint:fix": "eslint --fix \"*.{js,ts}\" \"src/**/*\" \"tasks/**/*\"", 28 | "pretest": "npm run lint", 29 | "prebuild": "npm run clean" 30 | }, 31 | "keywords": [], 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/microsoft/MHA.git" 35 | }, 36 | "author": "Stephen Griffin", 37 | "license": "MIT", 38 | "config": { 39 | "app_to_debug": "outlook", 40 | "app_type_to_debug": "desktop", 41 | "dev_server_port": 44336 42 | }, 43 | "bugs": { 44 | "url": "https://github.com/microsoft/MHA/issues" 45 | }, 46 | "homepage": "https://github.com/microsoft/MHA#readme", 47 | "devDependencies": { 48 | "@eslint/compat": "^2.0.0", 49 | "@eslint/eslintrc": "^3.3.3", 50 | "@eslint/js": "^9.39.2", 51 | "@jest/globals": "^30.2.0", 52 | "@stylistic/eslint-plugin": "^5.6.1", 53 | "@types/jest": "^30.0.0", 54 | "@types/office-js": "^1.0.565", 55 | "@typescript-eslint/eslint-plugin": "^8.50.0", 56 | "@typescript-eslint/parser": "^8.32.1", 57 | "copy-webpack-plugin": "^13.0.1", 58 | "css-loader": "^7.1.2", 59 | "eslint": "^9.39.2", 60 | "eslint-import-resolver-typescript": "^4.4.4", 61 | "eslint-plugin-import": "^2.32.0", 62 | "eslint-plugin-node": "^11.1.0", 63 | "exports-loader": "^5.0.0", 64 | "fork-ts-checker-webpack-plugin": "^9.1.0", 65 | "globals": "^16.5.0", 66 | "html-webpack-plugin": "^5.6.5", 67 | "jest": "^30.2.0", 68 | "jest-environment-jsdom": "^30.2.0", 69 | "jest-html-reporters": "^3.1.7", 70 | "mini-css-extract-plugin": "^2.9.4", 71 | "office-addin-debugging": "^6.0.6", 72 | "office-addin-dev-certs": "^2.0.3", 73 | "office-addin-dev-settings": "^3.0.3", 74 | "office-addin-manifest": "^2.0.3", 75 | "source-map-loader": "^5.0.0", 76 | "style-loader": "^4.0.0", 77 | "ts-jest": "^29.4.6", 78 | "ts-loader": "^9.5.4", 79 | "ts-node": "^10.9.2", 80 | "typescript": "^5.9.3", 81 | "webpack": "^5.104.1", 82 | "webpack-bundle-analyzer": "^5.1.0", 83 | "webpack-cli": "^6.0.1", 84 | "webpack-dev-server": "^5.2.2" 85 | }, 86 | "dependencies": { 87 | "@fluentui/web-components": "^2.6.1", 88 | "@microsoft/applicationinsights-web": "^3.3.10", 89 | "@microsoft/office-js": "^1.1.110", 90 | "codepage": "^1.15.0", 91 | "dayjs": "^1.11.19", 92 | "framework7": "^9.0.2", 93 | "framework7-icons": "^5.0.5", 94 | "jwt-decode": "^4.0.0", 95 | "promise-polyfill": "8.3.0", 96 | "stacktrace-js": "^2.0.2", 97 | "unfetch": "^5.0.0" 98 | }, 99 | "-vs-binding": { 100 | "BeforeBuild": [ 101 | "build:dev" 102 | ], 103 | "Clean": [ 104 | "clean" 105 | ] 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /deploy.cmd: -------------------------------------------------------------------------------- 1 | @if "%SCM_TRACE_LEVEL%" NEQ "4" @echo off 2 | 3 | :: ---------------------- 4 | :: KUDU Deployment Script 5 | :: Version: 1.0.17 6 | :: ---------------------- 7 | 8 | :: Prerequisites 9 | :: ------------- 10 | 11 | :: Verify node.js installed 12 | where node 2>nul >nul 13 | IF %ERRORLEVEL% NEQ 0 ( 14 | echo Missing node.js executable, please install node.js, if already installed make sure it can be reached from current environment. 15 | goto error 16 | ) 17 | 18 | :: Setup 19 | :: ----- 20 | 21 | setlocal enabledelayedexpansion 22 | 23 | SET ARTIFACTS=%~dp0%..\artifacts 24 | 25 | IF NOT DEFINED DEPLOYMENT_SOURCE ( 26 | SET DEPLOYMENT_SOURCE=%~dp0%. 27 | ) 28 | 29 | IF NOT DEFINED DEPLOYMENT_TARGET ( 30 | SET DEPLOYMENT_TARGET=%ARTIFACTS%\wwwroot 31 | ) 32 | 33 | IF NOT DEFINED NEXT_MANIFEST_PATH ( 34 | SET NEXT_MANIFEST_PATH=%ARTIFACTS%\manifest 35 | 36 | IF NOT DEFINED PREVIOUS_MANIFEST_PATH ( 37 | SET PREVIOUS_MANIFEST_PATH=%ARTIFACTS%\manifest 38 | ) 39 | ) 40 | 41 | echo NEXT_MANIFEST_PATH=%NEXT_MANIFEST_PATH% 42 | echo PREVIOUS_MANIFEST_PATH=%PREVIOUS_MANIFEST_PATH% 43 | 44 | IF NOT DEFINED KUDU_SYNC_CMD ( 45 | :: Install kudu sync 46 | echo Installing Kudu Sync 47 | call npm install kudusync -g --silent 48 | IF !ERRORLEVEL! NEQ 0 goto error 49 | 50 | :: Locally just running "kuduSync" would also work 51 | SET KUDU_SYNC_CMD=%appdata%\npm\kuduSync.cmd 52 | ) 53 | 54 | IF NOT DEFINED SYNC_CMD ( 55 | SET SYNC_CMD=robocopy.exe 56 | ) 57 | goto Deployment 58 | 59 | :: Utility Functions 60 | :: ----------------- 61 | 62 | :SelectNodeVersion 63 | 64 | IF DEFINED KUDU_SELECT_NODE_VERSION_CMD ( 65 | :: The following are done only on Windows Azure Websites environment 66 | echo %KUDU_SELECT_NODE_VERSION_CMD% "%DEPLOYMENT_SOURCE%" "%DEPLOYMENT_TARGET%" "%DEPLOYMENT_TEMP%" 67 | call %KUDU_SELECT_NODE_VERSION_CMD% "%DEPLOYMENT_SOURCE%" "%DEPLOYMENT_TARGET%" "%DEPLOYMENT_TEMP%" 68 | IF !ERRORLEVEL! NEQ 0 goto error 69 | 70 | IF EXIST "%DEPLOYMENT_TEMP%\__nodeVersion.tmp" ( 71 | SET /p NODE_EXE=<"%DEPLOYMENT_TEMP%\__nodeVersion.tmp" 72 | IF !ERRORLEVEL! NEQ 0 goto error 73 | ) 74 | 75 | IF EXIST "%DEPLOYMENT_TEMP%\__npmVersion.tmp" ( 76 | SET /p NPM_JS_PATH=<"%DEPLOYMENT_TEMP%\__npmVersion.tmp" 77 | IF !ERRORLEVEL! NEQ 0 goto error 78 | ) 79 | 80 | IF NOT DEFINED NODE_EXE ( 81 | SET NODE_EXE=node 82 | ) 83 | 84 | SET NPM_CMD="!NODE_EXE!" "!NPM_JS_PATH!" 85 | ) ELSE ( 86 | SET NPM_CMD=npm 87 | SET NODE_EXE=node 88 | ) 89 | 90 | goto :EOF 91 | 92 | :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: 93 | :: Deployment 94 | :: ---------- 95 | 96 | :Deployment 97 | echo Handling node.js deployment. 98 | 99 | echo 1. KuduSync 100 | IF /I "%IN_PLACE_DEPLOYMENT%" NEQ "1" ( 101 | "%SYNC_CMD%" "%DEPLOYMENT_SOURCE%" "%DEPLOYMENT_TARGET%" /s /mt 102 | IF !ERRORLEVEL! GEQ 8 goto error 103 | ) 104 | 105 | :: 2. Select node version 106 | echo 2. Select node version 107 | call :SelectNodeVersion 108 | 109 | :: 3. Install npm packages 110 | echo 3. Install npm packages 111 | IF EXIST "%DEPLOYMENT_TARGET%\package.json" ( 112 | echo Running npm install --production 113 | pushd "%DEPLOYMENT_TARGET%" 114 | call :ExecuteCmd !NPM_CMD! install --production 115 | IF !ERRORLEVEL! NEQ 0 goto error 116 | echo Completed npm install --production 117 | popd 118 | ) 119 | 120 | :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: 121 | goto end 122 | 123 | :: Execute command routine that will echo out when error 124 | :ExecuteCmd 125 | setlocal 126 | set _CMD_=%* 127 | call %_CMD_% 128 | if "%ERRORLEVEL%" NEQ "0" echo Failed exitCode=%ERRORLEVEL%, command=%_CMD_% 129 | exit /b %ERRORLEVEL% 130 | 131 | :error 132 | endlocal 133 | echo An error has occurred during web site deployment. 134 | call :exitSetErrorLevel 135 | call :exitFromFunction 2>nul 136 | 137 | :exitSetErrorLevel 138 | exit /b 1 139 | 140 | :exitFromFunction 141 | () 142 | 143 | :end 144 | endlocal 145 | echo Finished successfully. 146 | -------------------------------------------------------------------------------- /src/Scripts/row/Antispam.ts: -------------------------------------------------------------------------------- 1 | import { mhaStrings } from "../mhaStrings"; 2 | import { Strings } from "../Strings"; 3 | import { Header } from "./Header"; 4 | import { Row } from "./Row"; 5 | import { SummaryTable } from "../table/SummaryTable"; 6 | 7 | export class AntiSpamReport extends SummaryTable { 8 | public readonly tableName: string = "antiSpamReport"; 9 | public readonly displayName: string = mhaStrings.mhaAntiSpamReport; 10 | public readonly tag: string = "AS"; 11 | private sourceInternal = ""; 12 | private unparsedInternal = ""; 13 | private antiSpamRows: Row[] = [ 14 | new Row("BCL", mhaStrings.mhaBcl, "X-Microsoft-Antispam"), 15 | new Row("PCL", mhaStrings.mhaPcl, "X-Microsoft-Antispam"), 16 | new Row("source", mhaStrings.mhaSource, "X-Microsoft-Antispam"), 17 | new Row("unparsed", mhaStrings.mhaUnparsed, "X-Microsoft-Antispam") 18 | ]; 19 | 20 | public get rows(): Row[] { return this.antiSpamRows; } 21 | 22 | public existsInternal(rows: Row[]): boolean { 23 | for (const row of rows) { 24 | if (row.value) { 25 | return true; 26 | } 27 | } 28 | 29 | return false; 30 | } 31 | 32 | private setRowValue(rows: Row[], key: string, value: string): boolean { 33 | for (const row of rows) { 34 | if (row.header.toUpperCase() === key.toUpperCase()) { 35 | row.value = value; 36 | row.onGetUrl = (headerName: string, value: string) => { return Strings.mapHeaderToURL(headerName, value); }; 37 | return true; 38 | } 39 | } 40 | 41 | return false; 42 | } 43 | 44 | // https://docs.microsoft.com/en-us/microsoft-365/security/office-365-security/anti-spam-message-headers 45 | public parse(report: string): void { 46 | this.sourceInternal = report; 47 | if (!report) { 48 | return; 49 | } 50 | 51 | // Sometimes we see extraneous (null) in the report. They look like this: UIP:(null);(null);(null)SFV:SKI 52 | // First pass: Remove the (null). 53 | report = report.replace(/\(null\)/g, ""); 54 | 55 | // Occasionally, we find the final ; is missing. 56 | // Second pass: Add one. If it is extraneous, the next pass will remove it. 57 | report = report + ";"; 58 | 59 | // Removing the (null) can leave consecutive ; which confound later parsing. 60 | // Third pass: Collapse them. 61 | report = report.replace(/;+/g, ";"); 62 | 63 | const lines = report.match(/(.*?):(.*?);/g); 64 | this.unparsedInternal = ""; 65 | if (lines) { 66 | for (let iLine = 0; iLine < lines.length; iLine++) { 67 | const line = lines[iLine]?.match(/(.*?):(.*?);/m); 68 | if (line && line[1]) { 69 | if (line[2] === undefined || !this.setRowValue(this.rows, line[1], line[2])) { 70 | this.unparsedInternal += line[1] + ":" + line[2] + ";"; 71 | } 72 | } 73 | } 74 | } 75 | 76 | this.setRowValue(this.rows, "source", this.sourceInternal); 77 | this.setRowValue(this.rows, "unparsed", this.unparsedInternal); 78 | } 79 | 80 | public addInternal(report: string): void { this.parse(report); } 81 | public add(header: Header): boolean { 82 | if (header.header.toUpperCase() === "X-Microsoft-Antispam".toUpperCase()) { 83 | this.parse(header.value); 84 | return true; 85 | } 86 | 87 | return false; 88 | } 89 | 90 | public override exists(): boolean { return this.existsInternal(this.rows); } 91 | 92 | public get source(): string { return this.sourceInternal; } 93 | public get unparsed(): string { return this.unparsedInternal; } 94 | public override toString(): string { 95 | if (!this.exists()) return ""; 96 | const ret = ["AntiSpamReport"]; 97 | this.rows.forEach(function (row) { 98 | if (row.value) { ret.push(row.toString()); } 99 | }); 100 | return ret.join("\n"); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Scripts/table/Received.time.test.ts: -------------------------------------------------------------------------------- 1 | import "../jestMatchers/datesEqual"; 2 | import { expect } from "@jest/globals"; 3 | 4 | import { Received } from "./Received"; 5 | 6 | describe("Received Time Tests", () => { 7 | const received = new Received(); 8 | test("h1", () => { 9 | const h1 = "Received: test; Sat, 21 Apr 2018 03:01:01 +0000"; 10 | expect(received.parseHeader(h1)).datesEqual({ date: "4/20/2018, 11:01:01 PM", dateNum: 1524279661000 }); 11 | }); 12 | test("h2", () => { 13 | const h2 = "Received: test; Saturday, 21 Apr 2018 03:01:02 +0000"; 14 | expect(received.parseHeader(h2)).datesEqual({ date: "4/20/2018, 11:01:02 PM", dateNum: 1524279662000 }); 15 | }); 16 | test("h3", () => { 17 | const h3 = "Received: test; 21 Apr 2018 03:01:03 +0000"; 18 | expect(received.parseHeader(h3)).datesEqual({ date: "4/20/2018, 11:01:03 PM", dateNum: 1524279663000 }); 19 | }); 20 | test("h4", () => { 21 | const h4 = "Received: test; Apr 21 2018 03:01:04 +0000"; 22 | expect(received.parseHeader(h4)).datesEqual({ date: "4/20/2018, 11:01:04 PM", dateNum: 1524279664000 }); 23 | }); 24 | test("h5", () => { 25 | const h5 = "Received: test; Apr 21 2018 3:01:05 +0000"; 26 | expect(received.parseHeader(h5)).datesEqual({ date: "4/20/2018, 11:01:05 PM", dateNum: 1524279665000 }); 27 | }); 28 | test("h6", () => { 29 | const h6 = "Received: test; 4/20/2018 23:01:06 -0400 (EDT)"; 30 | expect(received.parseHeader(h6)).datesEqual({ date: "4/20/2018, 11:01:06 PM", dateNum: 1524279666000 }); 31 | }); 32 | test("h6_1", () => { 33 | const str = "Received: test; 4/20/2018 11:01:16 PM -0400 (EDT)"; 34 | expect(received.parseHeader(str)).datesEqual({ date: "4/20/2018, 11:01:16 PM", dateNum: 1524279676000 }); 35 | }); 36 | test("h6_2", () => { 37 | const str = "Received: test; 4/20/2018 11:01:26 PM +0000"; 38 | expect(received.parseHeader(str)).datesEqual({ date: "4/20/2018, 7:01:26 PM", dateNum: 1524265286000 }); 39 | }); 40 | test("h6_3", () => { 41 | const str = "Received: test; 4/20/2018 11:01:36 PM"; 42 | expect(received.parseHeader(str)).datesEqual({ date: "4/20/2018, 7:01:36 PM", dateNum: 1524265296000 }); 43 | }); 44 | test("h7", () => { 45 | const h7 = "Received: test; 4-20-2018 11:01:07 PM"; 46 | expect(received.parseHeader(h7)).datesEqual({ date: "4/20/2018, 7:01:07 PM", dateNum: 1524265267000 }); 47 | }); 48 | test("h8", () => { 49 | const h8 = "Received: test; 2018-4-20 11:01:08 PM"; 50 | expect(received.parseHeader(h8)).datesEqual({ date: "4/20/2018, 7:01:08 PM", dateNum: 1524265268000 }); 51 | }); 52 | test("h9", () => { 53 | const h9 = "Received: test; Mon, 26 Mar 2018 13:35:09 +0000 (UTC)"; 54 | expect(received.parseHeader(h9)).datesEqual({ date: "3/26/2018, 9:35:09 AM", dateNum: 1522071309000 }); 55 | }); 56 | test("h10", () => { 57 | const h10 = "Received: test; Mon, 26 Mar 2018 13:35:10.102 +0000 (UTC)"; 58 | expect(received.parseHeader(h10)).datesEqual({ date: "3/26/2018, 9:35:10 AM", dateNum: 1522071310102 }); 59 | }); 60 | test("h11", () => { 61 | const h11 = "Received: test; Mon, 26 Mar 2018 13:35:11.102 +0000 UTC"; 62 | expect(received.parseHeader(h11)).datesEqual({ date: "3/26/2018, 9:35:11 AM", dateNum: 1522071311102 }); 63 | }); 64 | 65 | test("50", () => { expect(Received.computeTime(9000, 8000)).toBe("1 second"); }); 66 | test("51", () => { expect(Received.computeTime(99000, 8000)).toBe("1 minute 31 seconds"); }); 67 | test("52", () => { expect(Received.computeTime(999000, 8000)).toBe("16 minutes 31 seconds"); }); 68 | test("53", () => { expect(Received.computeTime(9999000, 8000)).toBe("166 minutes 31 seconds"); }); 69 | test("54", () => { expect(Received.computeTime(8000, 9000)).toBe("-1 second"); }); 70 | test("55", () => { expect(Received.computeTime(8000, 99000)).toBe("-1 minute 31 seconds"); }); 71 | test("56", () => { expect(Received.computeTime(8000, 999000)).toBe("-16 minutes 31 seconds"); }); 72 | test("57", () => { expect(Received.computeTime(8000, 9999000)).toBe("-166 minutes 31 seconds"); }); 73 | test("58", () => { expect(Received.computeTime(9000, 8500)).toBe("0 seconds"); }); 74 | test("59", () => { expect(Received.computeTime(8500, 9000)).toBe("0 seconds"); }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/Scripts/Errors.ts: -------------------------------------------------------------------------------- 1 | import { diagnostics } from "./Diag"; 2 | import { Stack } from "./stacks"; 3 | import { Strings } from "./Strings"; 4 | 5 | let errorArray: string[] = []; 6 | 7 | export class Errors { 8 | public static clear(): void { errorArray = []; } 9 | 10 | public static get() { return errorArray; } 11 | 12 | public static add(eventName: string, stack: string[], suppressTracking: boolean): void { 13 | if (eventName || stack) { 14 | const stackString = Strings.joinArray(stack, "\n"); 15 | errorArray.push(Strings.joinArray([eventName, stackString], "\n")); 16 | 17 | if (!suppressTracking) { 18 | diagnostics.trackEvent({ name: eventName }, 19 | { 20 | stack: stackString, 21 | source: "Errors.add" 22 | }); 23 | } 24 | } 25 | } 26 | 27 | public static isError(error: unknown): boolean { 28 | if (!error) return false; 29 | 30 | // We can't afford to throw while checking if we're processing an error 31 | // So just swallow any exception and fail. 32 | try { 33 | if (typeof (error) === "string") return false; 34 | if (typeof (error) === "number") return false; 35 | if (typeof error === "object" && "stack" in error) return true; 36 | } catch (e) { 37 | diagnostics.trackEvent({ name: "isError exception with error", properties: { error: JSON.stringify(e) } }); 38 | } 39 | 40 | return false; 41 | } 42 | 43 | // error - an exception object 44 | // message - a string describing the error 45 | // suppressTracking - boolean indicating if we should suppress tracking 46 | public static log(error: unknown, message: string, suppressTracking?: boolean): void { 47 | if (error && !suppressTracking) { 48 | const event = { name: "Errors.log" }; 49 | const props = { 50 | message: message, 51 | error: JSON.stringify(error, null, 2), 52 | source: "", 53 | stack: "", 54 | description: "", 55 | errorMessage: "" 56 | }; 57 | 58 | if (Errors.isError(error) && (error as { exception?: unknown }).exception) { 59 | props.source = "Error.log Exception"; 60 | event.name = "Exception"; 61 | } 62 | else { 63 | props.source = "Error.log Event"; 64 | if (typeof error === "object" && "description" in error) props.description = (error as { description: string }).description; 65 | if (typeof error === "object" && "message" in error) props.errorMessage = (error as { message: string }).message; 66 | if (typeof error === "object" && "stack" in error) props.stack = (error as { stack: string }).stack; 67 | if (typeof error === "object" && "description" in error) { 68 | event.name = (error as { description: string }).description; 69 | } else if (typeof error === "object" && "message" in error) { 70 | event.name = (error as { message: string }).message; 71 | } else if (props.message) { 72 | event.name = props.message; 73 | } else { 74 | event.name = "Unknown error object"; 75 | } 76 | } 77 | 78 | diagnostics.trackException(event, props); 79 | } 80 | 81 | Stack.parse(error, message, function (eventName: string, stack: string[]): void { 82 | Errors.add(eventName, stack, suppressTracking ?? false); 83 | }); 84 | } 85 | 86 | public static logMessage(message:string): void { 87 | Errors.add(message, [], true); 88 | } 89 | 90 | public static getErrorMessage(error: unknown): string { 91 | if (!error) return ""; 92 | if (typeof (error) === "string") return error; 93 | if (typeof (error) === "number") return error.toString(); 94 | if (typeof error === "object" && error !== null && "message" in error) return (error as Error).message; 95 | return JSON.stringify(error, null, 2); 96 | } 97 | 98 | public static getErrorStack(error: unknown): string { 99 | if (!error) return ""; 100 | if (typeof (error) === "string") return "string thrown as error"; 101 | if (typeof (error) === "number") return "number thrown as error"; 102 | if (!Errors.isError(error)) return ""; 103 | if (typeof error === "object" && error !== null && "stack" in error) return (error as Error).stack ?? ""; 104 | return ""; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Scripts/ui/mha.ts: -------------------------------------------------------------------------------- 1 | import { 2 | fluentButton, 3 | provideFluentDesignSystem 4 | } from "@fluentui/web-components"; 5 | import "../../Content/fluentCommon.css"; 6 | import "../../Content/Office.css"; 7 | import "../../Content/classicDesktopFrame.css"; 8 | 9 | import { diagnostics } from "../Diag"; 10 | import { HeaderModel } from "../HeaderModel"; 11 | import { mhaStrings } from "../mhaStrings"; 12 | import { Strings } from "../Strings"; 13 | import { DomUtils } from "./domUtils"; 14 | import { Table } from "./Table"; 15 | 16 | // Register Fluent UI Web Components 17 | provideFluentDesignSystem().register( 18 | fluentButton() 19 | ); 20 | 21 | let viewModel: HeaderModel; 22 | let table: Table; 23 | 24 | function enableSpinner() { 25 | const responseElement = document.getElementById("response"); 26 | if (responseElement) { 27 | responseElement.style.backgroundImage = "url(../Resources/loader.gif)"; 28 | responseElement.style.backgroundRepeat = "no-repeat"; 29 | responseElement.style.backgroundPosition = "center"; 30 | } 31 | } 32 | 33 | function disableSpinner() { 34 | const responseElement = document.getElementById("response"); 35 | if (responseElement) { 36 | responseElement.style.background = "none"; 37 | } 38 | } 39 | 40 | const statusMessageTimeouts: Map = new Map(); 41 | 42 | function updateStatus(statusText: string) { 43 | DomUtils.setText("#status", statusText); 44 | if (viewModel !== null) { 45 | viewModel.status = statusText; 46 | } 47 | 48 | table.recalculateVisibility(); 49 | } 50 | 51 | function dismissAllStatusMessages() { 52 | // Clear all pending timeouts 53 | statusMessageTimeouts.forEach(timeoutId => { 54 | clearTimeout(timeoutId); 55 | }); 56 | statusMessageTimeouts.clear(); 57 | 58 | // Find all status overlay elements and hide them 59 | document.querySelectorAll(".status-overlay-inline.show").forEach(element => { 60 | element.classList.remove("show"); 61 | }); 62 | } 63 | 64 | function showStatusMessage(elementId: string, message: string, duration = 2000) { 65 | // Dismiss any currently showing status messages first 66 | dismissAllStatusMessages(); 67 | 68 | const statusElement = document.getElementById(elementId); 69 | if (statusElement) { 70 | // Update the message text 71 | statusElement.textContent = message; 72 | statusElement.classList.add("show"); 73 | 74 | // Hide after specified duration and track the timeout 75 | const timeoutId = setTimeout(() => { 76 | statusElement.classList.remove("show"); 77 | statusMessageTimeouts.delete(elementId); 78 | }, duration); 79 | 80 | statusMessageTimeouts.set(elementId, timeoutId); 81 | } 82 | } 83 | 84 | // Do our best at recognizing RFC 2822 headers: 85 | // http://tools.ietf.org/html/rfc2822 86 | async function analyze() { 87 | diagnostics.trackEvent({ name: "analyzeHeaders" }); 88 | const headerText = DomUtils.getValue("#inputHeaders"); 89 | 90 | if (!headerText.trim()) { 91 | showStatusMessage("analyzeStatusMessage", mhaStrings.mhaNoHeaders); 92 | return; 93 | } 94 | 95 | viewModel = await HeaderModel.create(headerText); 96 | table.resetArrows(); 97 | 98 | enableSpinner(); 99 | updateStatus(mhaStrings.mhaLoading); 100 | 101 | table.initializeTableUI(viewModel); 102 | updateStatus(""); 103 | 104 | disableSpinner(); 105 | 106 | showStatusMessage("analyzeStatusMessage", mhaStrings.mhaAnalyzed); 107 | } 108 | 109 | function clear() { 110 | DomUtils.setValue("#inputHeaders", ""); 111 | 112 | table.rebuildSections(null); 113 | document.getElementById("inputHeaders")?.focus(); 114 | 115 | showStatusMessage("clearStatusMessage", mhaStrings.mhaCleared); 116 | } 117 | 118 | function copy() { 119 | if (!viewModel || !viewModel.hasData) { 120 | showStatusMessage("copyStatusMessage", mhaStrings.mhaNothingToCopy); 121 | return; 122 | } 123 | 124 | Strings.copyToClipboard(viewModel.toString()); 125 | 126 | // Show accessible status message 127 | showStatusMessage("copyStatusMessage", mhaStrings.mhaCopied); 128 | 129 | document.getElementById("copyButton")?.focus(); 130 | } 131 | 132 | document.addEventListener("DOMContentLoaded", function() { 133 | diagnostics.set("API used", "standalone"); 134 | table = new Table(); 135 | table.initializeTableUI(); 136 | table.makeResizablePane("inputHeaders", "sectionHeader", mhaStrings.mhaPrompt, () => true); 137 | 138 | (document.querySelector("#analyzeButton") as HTMLButtonElement).onclick = analyze; 139 | (document.querySelector("#clearButton") as HTMLButtonElement).onclick = clear; 140 | (document.querySelector("#copyButton") as HTMLButtonElement).onclick = copy; 141 | }); 142 | -------------------------------------------------------------------------------- /src/Content/uiToggle.css: -------------------------------------------------------------------------------- 1 | /* Import shared Fluent UI styles */ 2 | @import url("fluentCommon.css"); 3 | 4 | body, html { 5 | width: 100%; 6 | height: 100%; 7 | margin: 0; 8 | padding: 0; 9 | font-family: var(--font-family); 10 | } 11 | 12 | .header-row { 13 | position: fixed; 14 | top: 0px; 15 | right: 0px; 16 | z-index: 10; 17 | background: transparent; 18 | display: flex; 19 | align-items: flex-start; 20 | min-height: var(--button-height); 21 | width: auto; 22 | height: var(--button-height); 23 | contain: layout style; 24 | } 25 | 26 | /* Status message overlay for copy feedback */ 27 | .status-overlay-fixed { 28 | position: fixed; 29 | top: 50px; /* Fixed position below header area */ 30 | right: 20px; /* Safe distance from edge */ 31 | } 32 | 33 | .frame-row { 34 | position: absolute; 35 | top: 0; 36 | left: 0; 37 | right: 0; 38 | bottom: 0; 39 | } 40 | 41 | .frame-row iframe { 42 | display: block; 43 | width: 100%; 44 | height: 100%; 45 | border: none; 46 | } 47 | 48 | /* Frame-specific overrides */ 49 | .code-box > pre { 50 | flex: none; 51 | height: auto; 52 | } 53 | 54 | /* Fluent UI Dialog customization */ 55 | fluent-dialog { 56 | --dialog-width: 400px; 57 | --dialog-height: auto; 58 | --dialog-border-radius: 8px; 59 | } 60 | 61 | fluent-dialog[hidden] { 62 | display: none !important; 63 | } 64 | 65 | .dialog-content { 66 | padding: 0 4px; 67 | display: flex; 68 | flex-direction: column; 69 | flex: 1; 70 | } 71 | 72 | /* Settings dialog specific content styling */ 73 | fluent-dialog[aria-label="Settings"] .dialog-content { 74 | border-top: 2px solid var(--background-lighter-gray); 75 | margin-top: 2px; 76 | } 77 | 78 | /* Privacy policy link alignment */ 79 | .privacy-link { 80 | text-align: right; 81 | } 82 | 83 | /* UI settings fieldset styling */ 84 | fieldset { 85 | border: 1px solid var(--border-gray); 86 | border-radius: var(--border-radius); 87 | padding: var(--spacing-medium); 88 | margin: 0 0 16px 0; 89 | } 90 | 91 | fieldset legend { 92 | padding: 0 8px; 93 | font-weight: 600; 94 | } 95 | 96 | fieldset fluent-checkbox { 97 | margin-top: 12px; 98 | } 99 | 100 | /* Ensure dialog action buttons are visible and properly styled */ 101 | .dialog-actions { 102 | display: flex; 103 | justify-content: flex-end; 104 | gap: 8px; 105 | margin-top: 0; 106 | padding: 16px 20px; 107 | border-top: 1px solid var(--text-gray); 108 | background: var(--white); 109 | border-radius: 0 0 8px 8px; 110 | flex-shrink: 0; 111 | } 112 | 113 | .dialog-actions fluent-button { 114 | min-width: 80px; 115 | } 116 | 117 | /* Dialog content spacing */ 118 | fluent-dialog > div { 119 | margin: 12px 0; 120 | } 121 | 122 | fluent-dialog fluent-radio-group { 123 | margin: 4px 0 2px 0; 124 | } 125 | 126 | fluent-dialog fluent-radio { 127 | margin: 3px 0; 128 | } 129 | 130 | fluent-dialog fluent-checkbox { 131 | margin: 2px 0 3px 0; 132 | } 133 | 134 | 135 | /* Remove all internal highlights from radio and checkbox controls */ 136 | fluent-radio::part(control), 137 | fluent-radio::part(control):focus, 138 | fluent-radio::part(control):focus-visible, 139 | fluent-radio::part(control):hover, 140 | fluent-checkbox::part(control), 141 | fluent-checkbox::part(control):focus, 142 | fluent-checkbox::part(control):focus-visible, 143 | fluent-checkbox::part(control):hover { 144 | outline: none !important; 145 | box-shadow: none !important; 146 | } 147 | 148 | /* High contrast mode support */ 149 | @media (prefers-contrast: high) { 150 | fluent-button, 151 | fluent-checkbox, 152 | fluent-radio { 153 | border: 2px solid ButtonText; 154 | } 155 | 156 | .code-box > pre { 157 | background-color: ButtonFace; 158 | border: 2px solid ButtonText; 159 | color: ButtonText; 160 | } 161 | } 162 | 163 | /* Privacy policy link styling */ 164 | fluent-dialog a { 165 | color: var(--primary-blue); 166 | text-decoration: none; 167 | } 168 | 169 | fluent-dialog a:hover { 170 | text-decoration: underline; 171 | } 172 | 173 | fluent-dialog a:focus { 174 | outline: var(--focus-outline); 175 | outline-offset: 2px; 176 | } 177 | 178 | /* DEBUG: Red border around currently focused element - only when debug mode is enabled */ 179 | .tab-navigation-debug *:focus { 180 | outline: 3px solid red !important; 181 | outline-offset: 2px !important; 182 | box-shadow: 0 0 0 5px rgba(255, 0, 0, 0.3) !important; 183 | } 184 | 185 | /* Make sure the red focus indicator is always visible in debug mode */ 186 | .tab-navigation-debug *:focus-visible { 187 | outline: 3px solid red !important; 188 | outline-offset: 2px !important; 189 | box-shadow: 0 0 0 5px rgba(255, 0, 0, 0.3) !important; 190 | } 191 | 192 | -------------------------------------------------------------------------------- /src/Scripts/ui/getHeaders/GetHeaders.ts: -------------------------------------------------------------------------------- 1 | import { GetHeadersAPI } from "./GetHeadersAPI"; 2 | import { GetHeadersEWS } from "./GetHeadersEWS"; 3 | import { GetHeadersRest } from "./GetHeadersRest"; 4 | import { diagnostics } from "../../Diag"; 5 | import { Errors } from "../../Errors"; 6 | import { ParentFrame } from "../../ParentFrame"; 7 | 8 | /* 9 | * GetHeaders.js 10 | * 11 | * Selector for switching between EWS and Rest logic 12 | */ 13 | 14 | export class GetHeaders { 15 | public static permissionLevel(): number { 16 | if (typeof (Office) === "undefined") return 0; 17 | if (!Office) return 0; 18 | if (!Office.context) return 0; 19 | if (!Office.context.mailbox) return 0; 20 | // @ts-expect-error early version of initialData 21 | if (Office.context.mailbox._initialData$p$0) return Office.context.mailbox._initialData$p$0._permissionLevel$p$0; 22 | // @ts-expect-error initialData is missing from the type file 23 | if (Office.context.mailbox.initialData) return Office.context.mailbox.initialData.permissionLevel; 24 | return 0; 25 | } 26 | 27 | public static sufficientPermission(strict: boolean): boolean { 28 | if (typeof (Office) === "undefined") return false; 29 | if (!Office) return false; 30 | if (!Office.context) return false; 31 | if (!Office.context.mailbox) return false; 32 | // In strict mode, we must find permissions to conclude we have them 33 | // In non-strict mode, if we don't find permissions, we assume we might have them 34 | // Some down level clients (such as we would use EWS on) don't have _initialData$p$0 or initialData at all. 35 | // @ts-expect-error initialData is missing from the type file 36 | if (!Office.context.mailbox._initialData$p$0 && !Office.context.mailbox.initialData) return !strict; 37 | if (GetHeaders.permissionLevel() < 1) return false; 38 | return true; 39 | } 40 | 41 | public static canUseAPI(apiType: string, minset: string): boolean { 42 | // if (apiType === "API") { return false; } 43 | // if (apiType === "Rest") { return false; } 44 | if (typeof (Office) === "undefined") { diagnostics.set(`no${apiType}reason`, "Office undefined"); return false; } 45 | if (!Office) { diagnostics.set(`no${apiType}reason`, "Office false"); return false; } 46 | if (!Office.context) { diagnostics.set("noUseRestReason", "context false"); return false; } 47 | if (!Office.context.requirements) { diagnostics.set("noUseRestReason", "requirements false"); return false; } 48 | if (!Office.context.requirements.isSetSupported("Mailbox", minset)) { diagnostics.set(`no${apiType}reason`, "requirements too low"); return false; } 49 | if (!GetHeaders.sufficientPermission(true)) { diagnostics.set(`no${apiType}reason`, "sufficientPermission false"); return false; } 50 | if (!Office.context.mailbox) { diagnostics.set(`no${apiType}reason`, "mailbox false"); return false; } 51 | if (!Office.context.mailbox.getCallbackTokenAsync) { diagnostics.set(`no${apiType}reason`, "getCallbackTokenAsync false"); return false; } 52 | return true; 53 | } 54 | 55 | public static validItem(): boolean { 56 | if (typeof (Office) === "undefined") return false; 57 | if (!Office) return false; 58 | if (!Office.context) return false; 59 | if (!Office.context.mailbox) return false; 60 | if (!Office.context.mailbox.item) return false; 61 | if (!Office.context.mailbox.item.itemId) return false; 62 | return true; 63 | } 64 | 65 | public static async send(headersLoadedCallback: (_headers: string, apiUsed: string) => void) { 66 | if (!GetHeaders.validItem()) { 67 | ParentFrame.showError(null, "No item selected", true); 68 | return; 69 | } 70 | 71 | if (!GetHeaders.sufficientPermission(false)) { 72 | ParentFrame.showError(null, "Insufficient permissions to request headers", false); 73 | return; 74 | } 75 | 76 | try { 77 | let headers:string = await GetHeadersAPI.send(); 78 | if (headers !== "") { 79 | headersLoadedCallback(headers, "API"); 80 | return; 81 | } 82 | 83 | Errors.logMessage("API failed, trying REST"); 84 | headers = await GetHeadersRest.send(); 85 | if (headers !== "") { 86 | headersLoadedCallback(headers, "REST"); 87 | return; 88 | } 89 | 90 | Errors.logMessage("REST failed, trying EWS"); 91 | headers = await GetHeadersEWS.send(); 92 | if (headers !== "") { 93 | headersLoadedCallback(headers, "EWS"); 94 | return; 95 | } 96 | } catch (e) { 97 | ParentFrame.showError(e, "Could not send header request"); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Content/newMobilePaneIosFrame.css: -------------------------------------------------------------------------------- 1 | @import url("themeColors.css"); 2 | 3 | /* Framework7 specific overrides */ 4 | 5 | :root { 6 | --code-box-bg: var(--background-light-gray); 7 | --border-light-grey: var(--border-gray); 8 | --focus-outline-blue: var(--focus-blue); 9 | --light-grey-border: var(--border-light-gray); 10 | --f7-block-title-text-color: var(--text-primary); 11 | --f7-progressbar-bg-color: var(--progress-track-bg); 12 | --f7-progressbar-progress-color: var(--primary-blue); 13 | --f7-timeline-item-inner-bg-color: var(--white); 14 | --f7-timeline-item-text-color: var(--text-primary); 15 | --f7-tabs-bg-color: var(--command-bar-gray); 16 | --f7-link-color: var(--link-default); 17 | --f7-accordion-chevron-icon-down: "chevron_right"; 18 | --f7-accordion-chevron-icon-up: "chevron_down"; 19 | --f7-timeline-item-inner-bg-color: var(--white); 20 | --f7-page-bg-color: var(-f7-tabs-bg-color); 21 | --f7-toolbar-bg-color: var(--background-light-blue); 22 | --f7-toolbar-text-color: var(--text-primary); 23 | --f7-tabbar-link-inactive-color: var(--text-secondary); 24 | --f7-tabbar-link-active-color: var(--link-active); 25 | } 26 | 27 | /* Reserve space for parent frame toolbar overlay */ 28 | body { 29 | margin: 0; 30 | padding: 0; 31 | } 32 | 33 | .framework7-root { 34 | position: absolute; 35 | left: 0; 36 | right: 0; 37 | bottom: 0; 38 | } 39 | 40 | .top-spacer { 41 | height: 44px; 42 | } 43 | 44 | /* Ensure page doesn't scroll behind toolbars */ 45 | .page { 46 | position: relative; 47 | height: calc(100vh - 44px - 24px); 48 | overflow-y: auto; 49 | } 50 | 51 | /* Framework7 v8 tab styling */ 52 | .tabs .tab { 53 | display: none; 54 | } 55 | 56 | .tabs .tab.tab-active { 57 | display: block; 58 | } 59 | 60 | .tab-link { 61 | color: var(--f7-link-color); 62 | } 63 | 64 | .code-box>pre { 65 | background-color: var(--code-box-bg); 66 | border: 1px solid var(--border-light-grey); 67 | padding: 5px 10px; 68 | word-wrap: break-word; 69 | white-space: pre-wrap; 70 | margin: 0; 71 | } 72 | 73 | #orig-headers-ui { 74 | display: none; 75 | } 76 | 77 | .timeline-item-subtitle, 78 | .timeline-item-text { 79 | word-wrap: break-word; 80 | } 81 | 82 | .wrap-line { 83 | word-wrap: break-word; 84 | } 85 | 86 | .block-title { 87 | text-transform: none; 88 | white-space: normal; 89 | margin-bottom: 0; 90 | } 91 | 92 | .tab .block-title:first-child { 93 | margin-top: 0px; 94 | } 95 | 96 | .block { 97 | word-wrap: break-word; 98 | margin-top: 0; 99 | } 100 | 101 | a { 102 | text-decoration: underline; 103 | } 104 | 105 | .tab-link:hover{ 106 | color: var(--link-hover); 107 | } 108 | 109 | a:focus, 110 | div:focus { 111 | outline: var(--focus-blue) solid 2px; 112 | outline-offset: 2px; 113 | } 114 | 115 | a.item-content, 116 | a.tab-link { 117 | outline-offset: -4px; 118 | text-decoration: none; 119 | } 120 | 121 | .block-title { 122 | overflow: visible; 123 | } 124 | 125 | .progressbar { 126 | border: 1px solid var(--black) !important; 127 | } 128 | 129 | @media screen and (-ms-high-contrast: active) { 130 | .progressbar span { 131 | background: highlight; 132 | } 133 | } 134 | 135 | @media (forced-colors: active) { 136 | .progressbar span { 137 | background: Highlight; 138 | } 139 | 140 | .tab-link.active, 141 | .tab-link.tab-link-active { 142 | color: ButtonText; 143 | } 144 | 145 | .tab-link-highlight { 146 | background: ButtonText !important; 147 | } 148 | } 149 | 150 | #antispam-view .block-title { 151 | color: var(--text-medium-grey); 152 | } 153 | 154 | #antispam-view .accordion-item { 155 | background: var(--white); 156 | border-top: 1px solid var(--light-grey-border); 157 | border-bottom: 1px solid var(--light-grey-border); 158 | } 159 | 160 | #antispam-view .accordion-item .item-title { 161 | font-size: 17px; 162 | background: var(--white); 163 | white-space: normal; 164 | word-wrap: break-word; 165 | overflow-wrap: break-word; 166 | } 167 | 168 | #antispam-view .accordion-item-content p { 169 | margin-top: 0 !important; 170 | } 171 | 172 | #received-view .timeline-item-inner { 173 | padding: 10px; 174 | margin: 5px 0; 175 | border-radius: 5px; 176 | display: flex; 177 | flex-direction: column; 178 | align-items: flex-start; 179 | text-align: left; 180 | word-wrap: break-word; 181 | } 182 | 183 | #received-view .timeline-item-time { 184 | font-weight: bold; 185 | margin-bottom: 5px; 186 | } 187 | 188 | #received-view .timeline-item-subtitle { 189 | margin-bottom: 5px; 190 | word-wrap: break-word; 191 | } 192 | 193 | #received-view .timeline-item-text { 194 | word-wrap: break-word; 195 | width: 100%; 196 | } 197 | 198 | #summary-view .block-title { 199 | margin: 0px; 200 | padding: 3px 0px; 201 | } 202 | 203 | #summary-view .block { 204 | margin: 5px 0; 205 | } 206 | 207 | #orig-headers-ui { 208 | margin-top: 0; 209 | } 210 | 211 | .hidden { 212 | display: none !important; 213 | } 214 | 215 | .visible { 216 | visibility: visible !important; 217 | } 218 | 219 | .invisible { 220 | visibility: hidden !important; 221 | } -------------------------------------------------------------------------------- /src/Scripts/table/DataTable.test.ts: -------------------------------------------------------------------------------- 1 | import { DataTable } from "./DataTable"; 2 | 3 | describe("DataTable", () => { 4 | // Concrete implementation for testing abstract DataTable 5 | class TestDataTable extends DataTable { 6 | public readonly tableName = "testDataTable"; 7 | public readonly displayName = "Test Data Table"; 8 | 9 | protected sortColumnInternal = "default"; 10 | protected sortOrderInternal = 1; 11 | 12 | private testRows: { id: number; name: string }[] = [ 13 | { id: 1, name: "First" }, 14 | { id: 2, name: "Second" } 15 | ]; 16 | 17 | public get rows(): { id: number; name: string }[] { 18 | return this.testRows; 19 | } 20 | 21 | public doSort(col: string): void { 22 | this.sortColumnInternal = col; 23 | this.sortOrderInternal = this.sortOrderInternal === 1 ? -1 : 1; 24 | 25 | // Simple sort implementation for testing 26 | this.testRows.sort((a, b) => { 27 | const aVal = a[col as keyof typeof a]; 28 | const bVal = b[col as keyof typeof b]; 29 | const result = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; 30 | return this.sortOrderInternal === 1 ? result : -result; 31 | }); 32 | } 33 | 34 | public toString(): string { 35 | return this.testRows.map(row => `${row.id}: ${row.name}`).join(", "); 36 | } 37 | 38 | // Test helpers 39 | public setRows(rows: { id: number; name: string }[]): void { 40 | this.testRows = rows; 41 | } 42 | } 43 | 44 | let dataTable: TestDataTable; 45 | 46 | beforeEach(() => { 47 | dataTable = new TestDataTable(); 48 | }); 49 | 50 | describe("DataTable inheritance", () => { 51 | it("should extend TableSection", () => { 52 | expect(dataTable.tableName).toBe("testDataTable"); 53 | expect(dataTable.displayName).toBe("Test Data Table"); 54 | }); 55 | 56 | it("should have tableCaption paneClass", () => { 57 | expect(dataTable.paneClass).toBe("tableCaption"); 58 | }); 59 | 60 | it("should inherit accessibility methods", () => { 61 | expect(dataTable.getTableCaption()).toBe("Test Data Table"); 62 | expect(dataTable.getAriaLabel()).toBe("Test Data Table table"); 63 | }); 64 | }); 65 | 66 | describe("Sorting functionality", () => { 67 | it("should have initial sort column and order", () => { 68 | expect(dataTable.sortColumn).toBe("default"); 69 | expect(dataTable.sortOrder).toBe(1); 70 | }); 71 | 72 | it("should change sort column and order when doSort is called", () => { 73 | dataTable.doSort("name"); 74 | expect(dataTable.sortColumn).toBe("name"); 75 | expect(dataTable.sortOrder).toBe(-1); // Flipped from initial 1 76 | }); 77 | 78 | it("should toggle sort order on same column", () => { 79 | dataTable.doSort("id"); 80 | expect(dataTable.sortOrder).toBe(-1); 81 | 82 | dataTable.doSort("id"); 83 | expect(dataTable.sortOrder).toBe(1); 84 | }); 85 | 86 | it("should sort rows when doSort is called", () => { 87 | // Sort by name 88 | dataTable.doSort("name"); 89 | const sortedRows = dataTable.rows; 90 | expect(sortedRows.length).toBe(2); 91 | expect(sortedRows[0]!.name).toBe("Second"); // Descending order 92 | expect(sortedRows[1]!.name).toBe("First"); 93 | }); 94 | }); 95 | 96 | describe("Rows management", () => { 97 | it("should have default rows", () => { 98 | expect(dataTable.rows).toHaveLength(2); 99 | expect(dataTable.rows[0]).toEqual({ id: 1, name: "First" }); 100 | expect(dataTable.rows[1]).toEqual({ id: 2, name: "Second" }); 101 | }); 102 | 103 | it("should allow rows to be updated", () => { 104 | const newRows = [{ id: 3, name: "Third" }]; 105 | dataTable.setRows(newRows); 106 | expect(dataTable.rows).toEqual(newRows); 107 | }); 108 | }); 109 | 110 | describe("Exists method", () => { 111 | it("should return true when rows exist", () => { 112 | expect(dataTable.exists()).toBe(true); 113 | }); 114 | 115 | it("should return false when no rows exist", () => { 116 | dataTable.setRows([]); 117 | expect(dataTable.exists()).toBe(false); 118 | }); 119 | }); 120 | 121 | describe("toString method", () => { 122 | it("should format rows as string", () => { 123 | expect(dataTable.toString()).toBe("1: First, 2: Second"); 124 | }); 125 | 126 | it("should handle empty rows", () => { 127 | dataTable.setRows([]); 128 | expect(dataTable.toString()).toBe(""); 129 | }); 130 | }); 131 | 132 | describe("Type checking", () => { 133 | it("should be instance of DataTable", () => { 134 | expect(dataTable).toBeInstanceOf(DataTable); 135 | }); 136 | 137 | it("should allow polymorphic usage with TableSection", () => { 138 | const baseRef = dataTable; 139 | expect(baseRef.getTableCaption()).toBe("Test Data Table"); 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MHA 2 | 3 | [![continuous-integration](https://github.com/microsoft/MHA/actions/workflows/build.yml/badge.svg)](https://github.com/microsoft/MHA/actions/workflows/build.yml) 4 | [![Deploy Test](https://github.com/microsoft/MHA/actions/workflows/buildDeployTest.yml/badge.svg)](https://github.com/microsoft/MHA/actions/workflows/buildDeployTest.yml) 5 | [![CodeQL](https://github.com/microsoft/MHA/actions/workflows/codeql.yml/badge.svg)](https://github.com/microsoft/MHA/actions/workflows/codeql.yml) 6 | [![Dependency Review](https://github.com/microsoft/MHA/actions/workflows/dependency-review.yml/badge.svg)](https://github.com/microsoft/MHA/actions/workflows/dependency-review.yml) 7 | [![Jest](https://github.com/microsoft/MHA/actions/workflows/jest.yml/badge.svg)](https://github.com/microsoft/MHA/actions/workflows/jest.yml) 8 | [![OpenSSF 9 | Scorecard](https://api.securityscorecards.dev/projects/github.com/microsoft/MHA/badge)](https://scorecard.dev/viewer/?uri=github.com%2Fmicrosoft%2FMHA) 10 | [![OpenSSF Best Practices](https://bestpractices.coreinfrastructure.org/projects/7511/badge)](https://bestpractices.coreinfrastructure.org/projects/7511) 11 | 12 | Message Header Analyzer mail app. 13 | 14 | This is the source for the Message Header Analyzer. Install the app from the store here: 15 | 16 | 17 | ## Installation Procedure 18 | 19 | Because MHA requires the ReadWriteMailbox permission it can only be installed by the Administrator through the Exchange admin center or by a user as a custom addon. Here are some steps I put together: 20 | 21 | 1. In Office365, go to the Exchange Admin Center. 22 | 1. Click on the Organization tab 23 | 1. From there, select the add-ins tab 24 | 1. Click the Plus icon/Add from the Office Store 25 | 1. Click the Plus icon/Add from the Office Store 26 | 1. A new page will load for the store 27 | 1. Search for "Message Header Analyzer" 28 | 1. Choose MHA in the results 29 | 1. Click Add 30 | 1. Confirm by clicking Yes 31 | 1. Back in the Exchange Admin Center, refresh the list of add-ins 32 | 1. You can now edit who the add-in is available for 33 | 34 | ## A Note on Permissions 35 | 36 | In order to get the transport message headers I have to use the EWS [makeEwsRequestAsync](https://learn.microsoft.com/en-us/javascript/api/outlook/office.mailbox?view=outlook-js-preview&preserve-view=true#outlook-office-mailbox-makeewsrequestasync-member(1)) method, which requires the ReadWriteMailbox permission level. See the article [Understanding Outlook add-in permissions](https://learn.microsoft.com/en-us/office/dev/add-ins/outlook/understanding-outlook-add-in-permissions) for more on this. If I could request fewer permissions I would, since I only ever read the one property, but I have no choice in the matter. 37 | 38 | When REST is more widely available, and a few REST specific bugs are fixed, I'll be able to switch to REST and request a lower permission level. 39 | 40 | ## Standalone 41 | 42 | Here is a standalone Message Header Analyzer running the same backend code as the MHA app: 43 | 44 | 45 | ## Unit Tests 46 | 47 | - [Unit tests](https://mha.azurewebsites.net/Pages/test) 48 | - [Code coverage](https://mha.azurewebsites.net/Pages/coverage/lcov-report) 49 | 50 | ## Mobile 51 | 52 | For both IOS and Android click open an email, then press the three dots under the date. There you should see the MHA icon. See [outlook-mobile-addins](https://learn.microsoft.com/en-us/office/dev/add-ins/outlook/outlook-mobile-addins) page for more details. 53 | 54 | ## Development & Custom Deployment 55 | 56 | ### Install and prereqs 57 | 58 | 1. Ensure [node.js (LTS)](https://nodejs.org/en) is installed 59 | 1. Clone the repo to your local drive 60 | 1. Run the following to install packages: `npm install` 61 | 62 | ### Manual build 63 | 64 | - The commands below for unit/site/add-in testing will also build before starting the server, but you can also build on demand. 65 | - Manual build: `npm run build` 66 | - For continuous build on changes, you can use watch: `npm run watch` 67 | 68 | ### Unit Testing 69 | 70 | - Start the dev server: `npm run dev-server` 71 | - Run unit tests from command line: `npm run test` 72 | - View test results here: 73 | - View code coverage here: 74 | - After changing source you will need to `npm run test` again. 75 | 76 | ### Live site testing 77 | 78 | - Start the dev server: `npm run dev-server` 79 | - Run website locally: 80 | - Changes made to source should live compile and reload page. Ctrl+R/refresh as needed. 81 | 82 | ### Add-in testing (Command line) 83 | 84 | - Close Outlook 85 | - Start the dev server: `npm start` 86 | - Outlook should start with add-in added as "View Headers Debug Local" 87 | - Changes made to source should live compile and reload in Outlook. Ctrl+R/refresh as needed. 88 | 89 | ### Add-in testing (VSCode) 90 | 91 | - Follow the steps given [here](https://learn.microsoft.com/en-us/office/dev/add-ins/testing/debug-desktop-using-edge-chromium#use-the-visual-studio-code-debugger). 92 | 93 | ### Add-in testing (Outlook Web App) 94 | 95 | - Start the dev server: `npm run dev-server` 96 | - Sideload your add-in here: 97 | - For detailed sideloading instructions, see: 98 | - Upload the `ManifestDebugLocal.xml` file when prompted 99 | - The add-in will appear as "View Headers Debug Local" in OWA 100 | 101 | ### Bundle Analysis 102 | 103 | - Start the dev server: `npm run dev-server` 104 | - Generate bundle analysis report: `npm run build:analyze` 105 | - This creates a production build with a detailed bundle composition report 106 | - View the report here: 107 | - The report shows: 108 | - Bundle size visualization with interactive treemap 109 | - Chunk breakdown and module dependencies 110 | - Optimization opportunities for large modules 111 | - Code splitting effectiveness analysis 112 | - Use this for performance auditing and identifying optimization opportunities 113 | 114 | ### Clean 115 | 116 | - Clean build artifacts: `npm clean` 117 | -------------------------------------------------------------------------------- /src/Scripts/table/SummaryTable.test.ts: -------------------------------------------------------------------------------- 1 | import { SummaryTable } from "./SummaryTable"; 2 | import { Row } from "../row/Row"; 3 | 4 | describe("SummaryTable", () => { 5 | // Concrete implementation for testing abstract SummaryTable 6 | class TestSummaryTable extends SummaryTable { 7 | public readonly tableName = "testSummaryTable"; 8 | public readonly displayName = "Test Summary Table"; 9 | public readonly tag = "TST"; 10 | 11 | private testRows: Row[] = []; 12 | 13 | constructor() { 14 | super(); 15 | // Create rows properly using Row constructor and set values 16 | const row1 = new Row("field1", "Field 1", ""); 17 | row1.value = "Value 1"; 18 | 19 | const row2 = new Row("field2", "Field 2", ""); 20 | row2.value = ""; // Empty value 21 | 22 | const row3 = new Row("field3", "Field 3", ""); 23 | row3.value = "Value 3"; 24 | 25 | this.testRows = [row1, row2, row3]; 26 | } 27 | 28 | public get rows(): Row[] { 29 | return this.testRows; 30 | } 31 | 32 | public toString(): string { 33 | return this.testRows 34 | .filter(row => !!row.value) 35 | .map(row => `${row.label}: ${row.value}`) 36 | .join(", "); 37 | } 38 | 39 | // Test helpers 40 | public setRows(rows: Row[]): void { 41 | this.testRows = rows; 42 | } 43 | } 44 | 45 | let summaryTable: TestSummaryTable; 46 | 47 | beforeEach(() => { 48 | summaryTable = new TestSummaryTable(); 49 | }); 50 | 51 | describe("SummaryTable inheritance", () => { 52 | it("should extend TableSection", () => { 53 | expect(summaryTable.tableName).toBe("testSummaryTable"); 54 | expect(summaryTable.displayName).toBe("Test Summary Table"); 55 | }); 56 | 57 | it("should have sectionHeader paneClass", () => { 58 | expect(summaryTable.paneClass).toBe("sectionHeader"); 59 | }); 60 | 61 | it("should inherit accessibility methods", () => { 62 | expect(summaryTable.getTableCaption()).toBe("Test Summary Table"); 63 | expect(summaryTable.getAriaLabel()).toBe("Test Summary Table table"); 64 | }); 65 | }); 66 | 67 | describe("Tag property", () => { 68 | it("should have correct tag", () => { 69 | expect(summaryTable.tag).toBe("TST"); 70 | }); 71 | 72 | it("should be readonly", () => { 73 | // TypeScript will catch this at compile time, but we can verify the property exists 74 | expect(typeof summaryTable.tag).toBe("string"); 75 | }); 76 | }); 77 | 78 | describe("Rows management", () => { 79 | it("should have default rows", () => { 80 | expect(summaryTable.rows).toHaveLength(3); 81 | expect(summaryTable.rows[0]!.label).toBe("Field 1"); 82 | expect(summaryTable.rows[0]!.value).toBe("Value 1"); 83 | }); 84 | 85 | it("should allow rows to be updated", () => { 86 | const newRow = new Row("newfield", "New Field", ""); 87 | newRow.value = "New Value"; 88 | summaryTable.setRows([newRow]); 89 | expect(summaryTable.rows).toEqual([newRow]); 90 | }); 91 | }); 92 | 93 | describe("Exists method", () => { 94 | it("should return true when any row has a value", () => { 95 | expect(summaryTable.exists()).toBe(true); 96 | }); 97 | 98 | it("should return false when no rows have values", () => { 99 | const emptyRow1 = new Row("empty1", "Empty 1", ""); 100 | emptyRow1.value = ""; 101 | const emptyRow2 = new Row("empty2", "Empty 2", ""); 102 | emptyRow2.value = ""; 103 | 104 | summaryTable.setRows([emptyRow1, emptyRow2]); 105 | expect(summaryTable.exists()).toBe(false); 106 | }); 107 | 108 | it("should return false when no rows exist", () => { 109 | summaryTable.setRows([]); 110 | expect(summaryTable.exists()).toBe(false); 111 | }); 112 | 113 | it("should handle mixed empty and non-empty values", () => { 114 | const emptyRow = new Row("empty", "Empty", ""); 115 | emptyRow.value = ""; 116 | 117 | const valueRow = new Row("hasvalue", "HasValue", ""); 118 | valueRow.value = "Some value"; 119 | 120 | const empty2Row = new Row("empty2", "Empty2", ""); 121 | empty2Row.value = ""; 122 | 123 | summaryTable.setRows([emptyRow, valueRow, empty2Row]); 124 | expect(summaryTable.exists()).toBe(true); 125 | }); 126 | }); 127 | 128 | describe("toString method", () => { 129 | it("should format rows with values as string", () => { 130 | expect(summaryTable.toString()).toBe("Field 1: Value 1, Field 3: Value 3"); 131 | }); 132 | 133 | it("should handle empty rows", () => { 134 | summaryTable.setRows([]); 135 | expect(summaryTable.toString()).toBe(""); 136 | }); 137 | 138 | it("should filter out rows without values", () => { 139 | const valueRow = new Row("hasvalue", "HasValue", ""); 140 | valueRow.value = "Test"; 141 | 142 | const emptyRow = new Row("empty", "Empty", ""); 143 | emptyRow.value = ""; 144 | 145 | summaryTable.setRows([valueRow, emptyRow]); 146 | expect(summaryTable.toString()).toBe("HasValue: Test"); 147 | }); 148 | }); 149 | 150 | describe("Type checking", () => { 151 | it("should be instance of SummaryTable", () => { 152 | expect(summaryTable).toBeInstanceOf(SummaryTable); 153 | }); 154 | 155 | it("should allow polymorphic usage with TableSection", () => { 156 | const baseRef = summaryTable; 157 | expect(baseRef.getTableCaption()).toBe("Test Summary Table"); 158 | }); 159 | }); 160 | 161 | describe("Different tag implementations", () => { 162 | it("should support different tag values", () => { 163 | // We can't test different implementations easily due to class count limits 164 | // but we can verify the tag property works 165 | expect(summaryTable.tag).toBe("TST"); 166 | expect(typeof summaryTable.tag).toBe("string"); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | 4 | import { fixupPluginRules } from "@eslint/compat"; 5 | import { FlatCompat } from "@eslint/eslintrc"; 6 | import js from "@eslint/js"; 7 | import stylistic from "@stylistic/eslint-plugin"; 8 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 9 | import tsParser from "@typescript-eslint/parser"; 10 | import importPlugin from "eslint-plugin-import"; 11 | import node from "eslint-plugin-node"; 12 | import globals from "globals"; 13 | 14 | const __filename = fileURLToPath(import.meta.url); 15 | const __dirname = path.dirname(__filename); 16 | const compat = new FlatCompat({ 17 | baseDirectory: __dirname, 18 | recommendedConfig: js.configs.recommended, 19 | allConfig: js.configs.all 20 | }); 21 | 22 | export default [{ 23 | ignores: ["Pages/**"], 24 | }, { 25 | files: ["**/*.ts","**/*.js"], 26 | }, ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"), { 27 | // Jest test files configuration 28 | files: ["**/*.test.{js,ts}", "**/*.spec.{js,ts}", "**/test/**", "**/tests/**"], 29 | languageOptions: { 30 | globals: { 31 | ...globals.browser, 32 | ...globals.jest, 33 | }, 34 | }, 35 | }, { 36 | plugins: { 37 | "@typescript-eslint": typescriptEslint, 38 | "@stylistic": stylistic, 39 | node, 40 | import: fixupPluginRules(importPlugin), 41 | }, 42 | 43 | languageOptions: { 44 | globals: { 45 | ...globals.browser, 46 | // Office.js globals 47 | Office: "readonly", 48 | // Webpack defined globals (replaced at build time) 49 | __AIKEY__: "readonly", 50 | __BUILDTIME__: "readonly", 51 | __VERSION__: "readonly", 52 | // Node.js/TypeScript globals used in specific contexts 53 | process: "readonly", 54 | global: "readonly", 55 | NodeJS: "readonly", 56 | // DOM/Web API globals that ESLint doesn't recognize by default 57 | EventListenerOrEventListenerObject: "readonly", 58 | AddEventListenerOptions: "readonly", 59 | PermissionDescriptor: "readonly", 60 | PermissionName: "readonly", 61 | }, 62 | 63 | parser: tsParser, 64 | ecmaVersion: "latest", 65 | sourceType: "module", 66 | }, 67 | 68 | settings: { 69 | "import/resolver": { 70 | typescript: { 71 | project: "./tsconfig.json", 72 | }, 73 | }, 74 | }, 75 | 76 | rules: { 77 | indent: ["error", 4, { 78 | SwitchCase: 1, 79 | }], 80 | 81 | "linebreak-style": ["error", "windows"], 82 | quotes: ["error", "double"], 83 | semi: ["error", "always"], 84 | "max-classes-per-file": ["error", 1], 85 | "no-duplicate-imports": "error", 86 | "no-inner-declarations": "error", 87 | "no-unmodified-loop-condition": "error", 88 | "block-scoped-var": "error", 89 | "no-undef": "error", // Catch undefined variables/functions 90 | "no-global-assign": "error", // Prevent accidental global overwrites 91 | 92 | camelcase: ["error", { 93 | properties: "always", 94 | }], 95 | 96 | "sort-imports": ["error", { 97 | ignoreDeclarationSort: true, 98 | }], 99 | 100 | "@stylistic/no-multiple-empty-lines": ["error", { 101 | max: 1, 102 | maxEOF: 0, 103 | maxBOF: 0, 104 | }], 105 | 106 | "@stylistic/no-trailing-spaces": "error", 107 | "@typescript-eslint/no-explicit-any": "error", 108 | "@typescript-eslint/no-inferrable-types": "error", 109 | 110 | "@typescript-eslint/naming-convention": ["error", { 111 | selector: "default", 112 | format: ["camelCase"], 113 | }, { 114 | selector: "variableLike", 115 | format: ["camelCase"], 116 | filter: { 117 | regex: "^(__filename|__dirname)$", 118 | match: false 119 | } 120 | }, { 121 | selector: "variable", 122 | filter: { 123 | regex: "^(__filename|__dirname)$", 124 | match: true 125 | }, 126 | format: null 127 | }, { 128 | selector: "import", 129 | format: ["camelCase", "PascalCase"] 130 | }, { 131 | selector: "typeLike", 132 | format: ["PascalCase"], 133 | }, { 134 | selector: "enumMember", 135 | format: ["PascalCase"], 136 | }, { 137 | selector: "property", 138 | format: ["camelCase"], 139 | filter: { 140 | regex: "^(@|import/|linebreak-style|max-classes-per-file|no-duplicate-imports|no-inner-declarations|no-unmodified-loop-condition|block-scoped-var|sort-imports|newlines-between|SwitchCase|no-undef|no-global-assign|Office|NodeJS|EventListenerOrEventListenerObject|AddEventListenerOptions|PermissionDescriptor|PermissionName|__[A-Z_]+__|\\^.+\\$)", 141 | match: false 142 | } 143 | }, { 144 | selector: "property", 145 | filter: { 146 | regex: "^(@|import/|linebreak-style|max-classes-per-file|no-duplicate-imports|no-inner-declarations|no-unmodified-loop-condition|block-scoped-var|sort-imports|newlines-between|SwitchCase|no-undef|no-global-assign|Office|NodeJS|EventListenerOrEventListenerObject|AddEventListenerOptions|PermissionDescriptor|PermissionName|__[A-Z_]+__|\\^.+\\$)", 147 | match: true 148 | }, 149 | format: null 150 | }, { 151 | selector: "method", 152 | format: ["camelCase"], 153 | }], 154 | 155 | "import/no-unresolved": "error", 156 | "import/no-named-as-default-member": "off", 157 | 158 | "import/order": ["error", { 159 | groups: [ 160 | "builtin", 161 | "external", 162 | "internal", 163 | ["sibling", "parent"], 164 | "index", 165 | "unknown", 166 | ], 167 | 168 | "newlines-between": "always", 169 | 170 | alphabetize: { 171 | order: "asc", 172 | caseInsensitive: true, 173 | }, 174 | }], 175 | }, 176 | }]; -------------------------------------------------------------------------------- /src/Pages/newMobilePaneIosFrame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | New Mobile Pane iOS Frame 6 | 7 | 8 | 9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | 32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | 47 | 67 |
68 |
69 | 70 | 80 | 81 | 88 | 89 | 96 | 97 | 107 | 108 | 116 | 117 | 120 | 121 | 135 | 136 | 142 | 143 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /src/Scripts/ui/getHeaders/GetHeadersRest.ts: -------------------------------------------------------------------------------- 1 | import { jwtDecode } from "jwt-decode"; 2 | 3 | import { GetHeaders } from "./GetHeaders"; 4 | import { diagnostics } from "../../Diag"; 5 | import { Errors } from "../../Errors"; 6 | import { mhaStrings } from "../../mhaStrings"; 7 | import { ParentFrame } from "../../ParentFrame"; 8 | 9 | /* 10 | * GetHeadersRest.js 11 | * 12 | * This file has all the methods to get PR_TRANSPORT_MESSAGE_HEADERS 13 | * from the current message via REST. 14 | * 15 | * Requirement Sets and Permissions 16 | * getCallbackTokenAsync requires 1.5 and ReadItem 17 | * convertToRestId requires 1.3 and Restricted 18 | * restUrl requires 1.5 and ReadItem 19 | */ 20 | 21 | export class GetHeadersRest { 22 | private static minRestSet = "1.5"; 23 | 24 | public static canUseRest(): boolean { return GetHeaders.canUseAPI("Rest", GetHeadersRest.minRestSet); } 25 | 26 | private static getItemRestId(): string { 27 | if (!Office.context.mailbox.item) return ""; 28 | // Currently the only Outlook Mobile version that supports add-ins 29 | // is Outlook for iOS. 30 | if (Office.context.mailbox.diagnostics.hostName === "OutlookIOS") { 31 | // itemId is already REST-formatted 32 | return Office.context.mailbox.item.itemId; 33 | } else { 34 | // Convert to an item ID for API v2.0 35 | return Office.context.mailbox.convertToRestId( 36 | Office.context.mailbox.item.itemId, 37 | Office.MailboxEnums.RestVersion.v2_0 38 | ); 39 | } 40 | } 41 | 42 | private static getBaseUrl(url: string): string { 43 | const parts = url.split("/"); 44 | 45 | return parts[0] + "//" + parts[2]; 46 | } 47 | 48 | private static getRestUrl(accessToken: string): string { 49 | // Shim function to workaround 50 | // mailbox.restUrl == null case 51 | if (Office.context.mailbox.restUrl) { 52 | return GetHeadersRest.getBaseUrl(Office.context.mailbox.restUrl); 53 | } 54 | 55 | // parse the token 56 | const jwt = jwtDecode(accessToken); 57 | 58 | // 'aud' parameter from token can be in a couple of 59 | // different formats. 60 | const aud = Array.isArray(jwt.aud) ? jwt.aud[0] : jwt.aud; 61 | 62 | if (aud) { 63 | // Format 1: It's just the URL 64 | if (aud.match(/https:\/\/([^@]*)/)) { 65 | return aud; 66 | } 67 | 68 | // Format 2: GUID/hostname@GUID 69 | const match = aud.match(/\/([^@]*)@/); 70 | if (match && match[1]) { 71 | return "https://" + match[1]; 72 | } 73 | } 74 | 75 | // Couldn't find what we expected, default to 76 | // outlook.office.com 77 | return "https://outlook.office.com"; 78 | } 79 | 80 | private static async getHeaders(accessToken: string): Promise { 81 | if (!accessToken || accessToken === "") { 82 | Errors.logMessage("No access token?"); 83 | return ""; 84 | } 85 | 86 | if (!Office.context.mailbox.item) { 87 | Errors.logMessage("No item"); 88 | return ""; 89 | } 90 | 91 | if (!Office.context.mailbox.item.itemId) { 92 | Errors.logMessage("No itemId"); 93 | return ""; 94 | } 95 | 96 | // Get the item's REST ID 97 | const itemId = GetHeadersRest.getItemRestId(); 98 | 99 | const getMessageUrl = GetHeadersRest.getRestUrl(accessToken) + 100 | "/api/v2.0/me/messages/" + 101 | itemId + 102 | // PR_TRANSPORT_MESSAGE_HEADERS 103 | "?$select=SingleValueExtendedProperties&$expand=SingleValueExtendedProperties($filter=PropertyId eq 'String 0x007D')"; 104 | 105 | try{ 106 | const response = await fetch(getMessageUrl, { 107 | headers: { 108 | "Authorization": "Bearer " + accessToken, //eslint-disable-line @typescript-eslint/naming-convention 109 | "Accept": "application/json; odata.metadata=none" //eslint-disable-line @typescript-eslint/naming-convention 110 | } 111 | }); 112 | 113 | if (!response.ok) { 114 | diagnostics.set("getHeadersFailure", JSON.stringify(response)); 115 | if (response.status === 0) { 116 | // Fallback to EWS now 117 | } else if (response.status === 404) { 118 | ParentFrame.showError(null, mhaStrings.mhaMessageMissing, true); 119 | } 120 | 121 | return ""; 122 | } 123 | 124 | const item = await response.json(); 125 | 126 | if (item.SingleValueExtendedProperties !== undefined) { 127 | return item.SingleValueExtendedProperties[0].Value; 128 | } else { 129 | ParentFrame.showError(null, mhaStrings.mhaHeadersMissing, true); 130 | return ""; 131 | } 132 | } 133 | catch (e) { 134 | ParentFrame.showError(e, "Failed parsing headers"); 135 | } 136 | 137 | return ""; 138 | } 139 | 140 | private static async getCallbackToken(): Promise { 141 | return new Promise((resolve) => { 142 | Office.context.mailbox.getCallbackTokenAsync((result) => { 143 | if (result.status === Office.AsyncResultStatus.Succeeded) { 144 | resolve(result.value); 145 | } else { 146 | diagnostics.set("callbackTokenFailure", JSON.stringify(result)); 147 | Errors.log(result.error, "Unable to obtain callback token.\nFallback to EWS.\n" + JSON.stringify(result, null, 2), true); 148 | resolve(""); 149 | } 150 | }); 151 | }); 152 | } 153 | 154 | public static async send(): Promise { 155 | if (!GetHeaders.validItem()) { 156 | Errors.logMessage("No item selected (REST)"); 157 | return ""; 158 | } 159 | 160 | if (!GetHeadersRest.canUseRest()) { 161 | return ""; 162 | } 163 | 164 | ParentFrame.updateStatus(mhaStrings.mhaRequestSent); 165 | 166 | try { 167 | const accessToken= await GetHeadersRest.getCallbackToken(); 168 | const headers = await GetHeadersRest.getHeaders(accessToken); 169 | 170 | return headers; 171 | } 172 | catch (e) { 173 | ParentFrame.showError(e, "Failed using getCallbackTokenAsync"); 174 | } 175 | 176 | return ""; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Scripts/ui/getHeaders/GetHeadersEWS.ts: -------------------------------------------------------------------------------- 1 | import { GetHeaders } from "./GetHeaders"; 2 | import { Errors } from "../../Errors"; 3 | import { mhaStrings } from "../../mhaStrings"; 4 | import { ParentFrame } from "../../ParentFrame"; 5 | 6 | /* 7 | * GetHeadersEWS.js 8 | * 9 | * This file has all the methods to get PR_TRANSPORT_MESSAGE_HEADERS 10 | * from the current message via EWS. 11 | * 12 | * Requirement Sets and Permissions 13 | * makeEwsRequestAsync requires 1.0 and ReadWriteMailbox 14 | */ 15 | interface HeaderProp { 16 | prop: string; 17 | responseCode: string; 18 | } 19 | 20 | export class GetHeadersEWS { 21 | public static extractHeadersFromXml(xml: string): HeaderProp { 22 | // This filters nodes for the one that matches the given name. 23 | function filterNode(xmlDocument: XMLDocument, node: string): string { 24 | const elements = xmlDocument.getElementsByTagName("*"); 25 | for (let i = 0; i < elements.length; i++) { 26 | const element = elements[i]; 27 | if (element && element.nodeName === node && element.textContent) { 28 | return element.textContent.replace(/\r\n|\r|\n/g, "\n"); 29 | } 30 | } 31 | return ""; 32 | } 33 | 34 | const ret = {} as HeaderProp; 35 | try { 36 | // Strip encoded embedded null characters from our XML. parseXML doesn't like them. 37 | xml = xml.replace(/�/g, ""); 38 | const parser = new DOMParser(); 39 | const response = parser.parseFromString(xml, "text/xml"); 40 | 41 | if (response && !response.querySelector("parsererror")) { 42 | // We can do this because we know there's only the one property. 43 | const extendedProperty = filterNode(response, "t:ExtendedProperty"); 44 | if (extendedProperty) { 45 | ret.prop = extendedProperty; 46 | } 47 | 48 | if (!ret.prop) { 49 | ret.responseCode = filterNode(response, "m:ResponseCode"); 50 | } 51 | } 52 | } catch { 53 | // Exceptions thrown from parseXML are super chatty and we do not want to log them. 54 | // We throw this exception away and just return nothing. 55 | } 56 | 57 | return ret; 58 | } 59 | 60 | private static stripHeaderFromXml(xml: string): string { 61 | if (!xml) return ""; 62 | return xml 63 | .replace(/[\s\S]*<\/t:Value>/g, "redacted") 64 | .replace(//g, ""); 65 | } 66 | 67 | // Function called when the EWS request is complete. 68 | private static callbackEws(asyncResult: Office.AsyncResult): string { 69 | try { 70 | // Process the returned response here. 71 | let header = null; 72 | if (asyncResult.value) { 73 | header = GetHeadersEWS.extractHeadersFromXml(asyncResult.value); 74 | 75 | // We might not have a prop and also no error. This is OK if the prop is just missing. 76 | if (!header.prop) { 77 | if (header.responseCode === "NoError") { 78 | ParentFrame.showError(null, mhaStrings.mhaHeadersMissing, true); 79 | return ""; 80 | } 81 | } 82 | } 83 | 84 | if (header && header.prop) { 85 | return header.prop; 86 | } 87 | else { 88 | throw new Error(mhaStrings.mhaRequestFailed); 89 | } 90 | } 91 | catch (e) { 92 | if (asyncResult) { 93 | Errors.log(asyncResult.error, "Async Response\n" + GetHeadersEWS.stripHeaderFromXml(JSON.stringify(asyncResult, null, 2))); 94 | } 95 | 96 | ParentFrame.showError(e, "EWS callback failed"); 97 | } 98 | 99 | return ""; 100 | } 101 | 102 | private static getSoapEnvelope(request: string): string { 103 | // Wrap an Exchange Web Services request in a SOAP envelope. 104 | return "" + 105 | "" + 107 | " " + 108 | " " + 109 | " " + 110 | " " + 111 | request + 112 | " " + 113 | ""; 114 | } 115 | 116 | private static getHeadersRequest(id: string): string { 117 | // Return a GetItem EWS operation request for the headers of the specified item. 118 | return "" + 119 | " " + 120 | " IdOnly" + 121 | " Text" + 122 | " " + 123 | // PR_TRANSPORT_MESSAGE_HEADERS 124 | " " + 125 | " " + 126 | " " + 127 | " " + 128 | ""; 129 | } 130 | 131 | private static async makeEwsRequest(mailbox: Office.Mailbox, envelope:string): Promise { 132 | return new Promise((resolve) => { 133 | mailbox.makeEwsRequestAsync(envelope, function (asyncResult: Office.AsyncResult) { 134 | resolve(GetHeadersEWS.callbackEws(asyncResult)); 135 | }); 136 | }); 137 | } 138 | 139 | public static async send(): Promise { 140 | if (!GetHeaders.validItem()) { 141 | Errors.logMessage("No item selected (EWS)"); 142 | return ""; 143 | } 144 | 145 | try { 146 | ParentFrame.updateStatus(mhaStrings.mhaRequestSent); 147 | const mailbox = Office.context.mailbox; 148 | if (mailbox && mailbox.item) { 149 | const request = GetHeadersEWS.getHeadersRequest(mailbox.item.itemId); 150 | const envelope = GetHeadersEWS.getSoapEnvelope(request); 151 | const headers = await GetHeadersEWS.makeEwsRequest(mailbox, envelope); 152 | return headers; 153 | } 154 | } catch (e2) { 155 | ParentFrame.showError(e2, mhaStrings.mhaRequestFailed); 156 | } 157 | 158 | return ""; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/Scripts/HeaderModel.ts: -------------------------------------------------------------------------------- 1 | import { Decoder } from "./2047"; 2 | import { Poster } from "./Poster"; 3 | import { AntiSpamReport } from "./row/Antispam"; 4 | import { ForefrontAntiSpamReport } from "./row/ForefrontAntispam"; 5 | import { Header } from "./row/Header"; 6 | import { Summary } from "./Summary"; 7 | import { Other } from "./table/Other"; 8 | import { Received } from "./table/Received"; 9 | 10 | export class HeaderModel { 11 | public originalHeaders: string; 12 | public summary: Summary; 13 | public receivedHeaders: Received; 14 | public forefrontAntiSpamReport: ForefrontAntiSpamReport; 15 | public antiSpamReport: AntiSpamReport; 16 | public otherHeaders: Other; 17 | private hasDataInternal: boolean; 18 | private statusInternal: string; 19 | public get hasData(): boolean { return this.hasDataInternal || !!this.statusInternal; } 20 | public get status(): string { return this.statusInternal; } 21 | public set status(value) { this.statusInternal = value; } 22 | [index: string]: unknown; 23 | 24 | private constructor() { 25 | this.summary = new Summary(); 26 | this.receivedHeaders = new Received(); 27 | this.forefrontAntiSpamReport = new ForefrontAntiSpamReport(); 28 | this.antiSpamReport = new AntiSpamReport(); 29 | this.otherHeaders = new Other(); 30 | this.originalHeaders = ""; 31 | this.statusInternal = ""; 32 | this.hasDataInternal = false; 33 | } 34 | 35 | public static async create(headers?: string): Promise { 36 | const model = new HeaderModel(); 37 | 38 | if (headers) { 39 | model.parseHeaders(headers); 40 | Poster.postMessageToParent("modelToString", model.toString()); 41 | } 42 | 43 | return model; 44 | } 45 | 46 | public static getHeaderList(headers: string): Header[] { 47 | // First, break up out input by lines. 48 | // Keep empty lines for recognizing the boundary between the header section & the body. 49 | const lines: string[] = headers.split(/\r\n|\r|\n/); 50 | 51 | const headerList: Header[] = []; 52 | let iNextHeader = 0; 53 | let prevHeader: Header | undefined; 54 | let body = false; 55 | headerSection: while (!body) { 56 | unfoldLines: for (let line of lines) { 57 | // Handling empty lines. The body is separated from the header section by an empty line (RFC 5322, 2.1). 58 | // To avoid processing the body as headers we should stop there, as someone might paste an entire message. 59 | // Empty lines at the beginning can be omitted, because that could be a common copy-paste error. 60 | if (body) break headerSection; 61 | if (line === "") { 62 | if (headerList.length > 0) body = true; 63 | continue unfoldLines; 64 | } 65 | 66 | // Recognizing a header: 67 | // - First colon comes before first white space. 68 | // - We're not strictly honoring white space folding because initial white space 69 | // - is commonly lost. Instead, we heuristically assume that space before a colon must have been folded. 70 | // This expression will give us: 71 | // match[1] - everything before the first colon, assuming no spaces (header). 72 | // match[2] - everything after the first colon (value). 73 | const match: RegExpMatchArray | null = line.match(/(^[\w-.]*?): ?(.*)/); 74 | 75 | // There's one false positive we might get: if the time in a Received header has been 76 | // folded to the next line, the line might start with something like "16:20:05 -0400". 77 | // This matches our regular expression. The RFC does not preclude such a header, but I've 78 | // never seen one in practice, so we check for and exclude 'headers' that 79 | // consist only of 1 or 2 digits. 80 | if (match && match[1] && !match[1].match(/^\d{1,2}$/)) { 81 | headerList[iNextHeader] = new Header(match[1], match[2] ?? ""); 82 | prevHeader = headerList[iNextHeader]; 83 | iNextHeader++; 84 | } else { 85 | if (iNextHeader > 0) { 86 | // Tack this line to the previous line 87 | // All folding whitespace should collapse to a single space 88 | line = line.replace(/^[\s]+/, ""); 89 | if (!line) continue unfoldLines; 90 | if (prevHeader) { 91 | const separator: string = prevHeader.value ? " " : ""; 92 | prevHeader.value += separator + line; 93 | } 94 | } else { 95 | // If we didn't have a previous line, go ahead and use this line 96 | if (line.match(/\S/g)) { 97 | headerList[iNextHeader] = new Header("", line); 98 | prevHeader = headerList[iNextHeader]; 99 | iNextHeader++; 100 | } 101 | } 102 | } 103 | } 104 | break headerSection; 105 | } 106 | 107 | // 2047 decode our headers now 108 | headerList.forEach((header: Header) => { 109 | // Clean 2047 encoding 110 | // Strip nulls 111 | // Strip trailing carriage returns 112 | header.value = Decoder.clean2047Encoding(header.value).replace(/\0/g, "").replace(/[\n\r]+$/, ""); 113 | }); 114 | 115 | return headerList; 116 | } 117 | 118 | public parseHeaders(headers: string): void { 119 | // Initialize originalHeaders in case we have parsing problems 120 | // Flatten CRLF to LF to avoid extra blank lines 121 | this.originalHeaders = headers.replace(/(?:\r\n|\r|\n)/g, "\n"); 122 | const headerList: Header[] = HeaderModel.getHeaderList(headers); 123 | 124 | if (headerList.length > 0) { 125 | this.hasDataInternal = true; 126 | } 127 | 128 | headerList.forEach((header: Header) => { 129 | // Grab values for our summary pane 130 | if (this.summary.add(header)) return; 131 | 132 | // Properties with special parsing 133 | if (this.forefrontAntiSpamReport.add(header)) return; 134 | if (this.antiSpamReport.add(header)) return; 135 | if (this.receivedHeaders.add(header)) return; 136 | this.otherHeaders.add(header); 137 | }); 138 | 139 | this.summary.totalTime = this.receivedHeaders.computeDeltas(); 140 | } 141 | 142 | public toString(): string { 143 | const ret: string[] = []; 144 | if (this.summary.exists()) ret.push(this.summary.toString()); 145 | if (this.receivedHeaders.exists()) ret.push(this.receivedHeaders.toString()); 146 | if (this.forefrontAntiSpamReport.exists()) ret.push(this.forefrontAntiSpamReport.toString()); 147 | if (this.antiSpamReport.exists()) ret.push(this.antiSpamReport.toString()); 148 | if (this.otherHeaders.exists()) ret.push(this.otherHeaders.toString()); 149 | return ret.join("\n\n"); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Scripts/Errors.test.ts: -------------------------------------------------------------------------------- 1 | import "./jestMatchers/stacksEqual"; 2 | import { expect } from "@jest/globals"; 3 | 4 | import { Errors } from "./Errors"; 5 | import { Stack } from "./stacks"; 6 | 7 | function testParse(done: jest.DoneCallback, exception: unknown, message: string | null, expectedEventName: string, expectedStack: string[]) { 8 | Stack.parse(exception, message, function (eventName, stack) { 9 | try { 10 | expect(eventName).toBe(expectedEventName); 11 | expect(stack).stacksEqual(expectedStack); 12 | done(); 13 | } catch (error) { 14 | done(error); 15 | } 16 | }); 17 | } 18 | 19 | describe("Errors.parse Tests", () => { 20 | beforeAll(() => { Stack.options.offline = true; }); 21 | 22 | test("stringError", done => { 23 | testParse(done, "stringError", "message", "message : stringError", [ 24 | "testParse (src\\Scripts\\Errors.test.ts)", 25 | "Object. (src\\Scripts\\Errors.test.ts)", 26 | "processTicksAndRejections (node:internal/process/task_queues)" 27 | ]); 28 | }); 29 | 30 | test("notAFunction", done => { 31 | try { 32 | // @ts-expect-error Intentional error to test error handling 33 | document.notAFunction(); 34 | } 35 | catch (error) { 36 | testParse(done, error, null, "document.notAFunction is not a function", 37 | [ 38 | "Object. (src\\Scripts\\Errors.test.ts)", 39 | "processTicksAndRejections (node:internal/process/task_queues)" 40 | ]); 41 | } 42 | }); 43 | 44 | test("Throw integer", done => { 45 | try { 46 | throw 42; 47 | } 48 | catch (error) { 49 | testParse(done, error, "message", "message : 42", 50 | [ 51 | "testParse (src\\Scripts\\Errors.test.ts)", 52 | "Object. (src\\Scripts\\Errors.test.ts)", 53 | "processTicksAndRejections (node:internal/process/task_queues)" 54 | ]); 55 | } 56 | }); 57 | 58 | test("Throw array", done => { 59 | try { 60 | throw { one: 1, two: 2, three: "three" }; 61 | } 62 | catch (error) { 63 | testParse(done, error, null, 64 | "{\n" + 65 | " \"one\": 1,\n" + 66 | " \"two\": 2,\n" + 67 | " \"three\": \"three\"\n" + 68 | "}", 69 | [ 70 | "testParse (src\\Scripts\\Errors.test.ts)", 71 | "Object. (src\\Scripts\\Errors.test.ts)", 72 | "processTicksAndRejections (node:internal/process/task_queues)" 73 | ]); 74 | } 75 | }); 76 | 77 | test("Throw null", done => { 78 | try { 79 | throw null; 80 | } 81 | catch (error) { 82 | testParse(done, error, null, "Unknown exception", 83 | [ 84 | "testParse (src\\Scripts\\Errors.test.ts)", 85 | "Object. (src\\Scripts\\Errors.test.ts)", 86 | "processTicksAndRejections (node:internal/process/task_queues)" 87 | ]); 88 | } 89 | }); 90 | 91 | test("null error and string message", done => { 92 | testParse(done, null, "message", "message", [ 93 | "testParse (src\\Scripts\\Errors.test.ts)", 94 | "Object. (src\\Scripts\\Errors.test.ts)", 95 | "processTicksAndRejections (node:internal/process/task_queues)" 96 | ]); 97 | }); 98 | 99 | test("null error and null message", done => { 100 | testParse(done, null, null, "Unknown exception", [ 101 | "testParse (src\\Scripts\\Errors.test.ts)", 102 | "Object. (src\\Scripts\\Errors.test.ts)", 103 | "processTicksAndRejections (node:internal/process/task_queues)" 104 | ]); 105 | }); 106 | 107 | test("new Error()", done => { 108 | const brokenError = new Error(); 109 | testParse(done, brokenError, null, "Unknown exception", [ 110 | "Object. (src\\Scripts\\Errors.test.ts)", 111 | "processTicksAndRejections (node:internal/process/task_queues)" 112 | ]); 113 | }); 114 | 115 | test("integer error and string message", done => { 116 | testParse(done, 42, "message", "message : 42", [ 117 | "testParse (src\\Scripts\\Errors.test.ts)", 118 | "Object. (src\\Scripts\\Errors.test.ts)", 119 | "processTicksAndRejections (node:internal/process/task_queues)" 120 | ]); 121 | }); 122 | }); 123 | 124 | describe("getError* Tests", () => { 125 | test("notAFunction error", () => { 126 | try { 127 | // @ts-expect-error Intentional error to test error handling 128 | document.notAFunction(); 129 | } 130 | catch (error) { 131 | expect(Errors.getErrorMessage(error)).toEqual("document.notAFunction is not a function"); 132 | expect(Errors.getErrorStack(error).length).toBeGreaterThan(0); 133 | } 134 | }); 135 | 136 | test("string thrown as error", () => { 137 | try { 138 | throw "string"; 139 | } 140 | catch (error) { 141 | expect(Errors.getErrorMessage(error)).toEqual("string"); 142 | expect(Errors.getErrorStack(error)).toEqual("string thrown as error"); 143 | } 144 | }); 145 | 146 | test("number thrown as error", () => { 147 | try { 148 | throw 42; 149 | } 150 | catch (error) { 151 | expect(Errors.getErrorMessage(error)).toEqual("42"); 152 | expect(Errors.getErrorStack(error)).toEqual("number thrown as error"); 153 | } 154 | }); 155 | 156 | test("object thrown as error", () => { 157 | try { 158 | throw { one: 1, two: 2, three: "three" }; 159 | } 160 | catch (error) { 161 | expect(Errors.getErrorMessage(error)).toEqual("{\n" + 162 | " \"one\": 1,\n" + 163 | " \"two\": 2,\n" + 164 | " \"three\": \"three\"\n" + 165 | "}"); 166 | expect(Errors.getErrorStack(error).length).toBe(0); 167 | } 168 | }); 169 | 170 | test("null error message", () => { expect(Errors.getErrorMessage(null)).toBe(""); }); 171 | test("null errorstack", () => { expect(Errors.getErrorStack(null)).toBe(""); }); 172 | 173 | test("string error message", () => { expect(Errors.getErrorMessage("stringError")).toBe("stringError"); }); 174 | test("string errorstack", () => { expect(Errors.getErrorStack("stringError")).toBe("string thrown as error"); }); 175 | 176 | test("42 error message", () => { expect(Errors.getErrorMessage(42)).toBe("42"); }); 177 | test("42 errorstack", () => { expect(Errors.getErrorStack(42)).toBe("number thrown as error"); }); 178 | }); 179 | 180 | describe("isError Tests", () => { 181 | // @ts-expect-error Intentional error to test error handling 182 | try { document.notAFunction(); } catch (error) { expect(Errors.isError(error)).toBeTruthy(); } 183 | try { throw null; } catch (error) { expect(Errors.isError(error)).toBeFalsy(); } 184 | try { throw "string"; } catch (error) { expect(Errors.isError(error)).toBeFalsy(); } 185 | try { throw 42; } catch (error) { expect(Errors.isError(error)).toBeFalsy(); } 186 | 187 | expect(Errors.isError("string")).toBeFalsy(); 188 | expect(Errors.isError(42)).toBeFalsy(); 189 | expect(Errors.isError(null)).toBeFalsy(); 190 | }); 191 | --------------------------------------------------------------------------------