├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── codeql.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── MSGraphPlugin.ts ├── ObsidianTokenCachePlugin.ts ├── README.md ├── authConfig.ts ├── authProvider.ts ├── calendarHandler.ts ├── defaultEventTemplate.ts ├── defaultFlaggedMailTemplate.ts ├── defaultMailTemplate.ts ├── esbuild.config.mjs ├── mailHandler.ts ├── manifest.json ├── msgraphPluginSettings.ts ├── package-lock.json ├── package.json ├── patches └── fetch+1.1.0.patch ├── selectMailFolderModal.ts ├── styles.css ├── tsconfig.json ├── types.ts ├── update_reply_urls.ps1 ├── util.ts ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | insert_final_newline = true 7 | indent_style = tab 8 | indent_size = 4 9 | tab_width = 4 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.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: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '28 13 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v2 71 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian Plugin 2 | on: 3 | push: 4 | # Sequence of patterns matched against refs/tags 5 | tags: 6 | - '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | fetch-depth: 0 # otherwise, you will failed to push refs to dest repo 14 | - name: Use Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: '16.x' # You might need to adjust this value to your own version 18 | # Get the version number and put it in a variable 19 | - name: Get Version 20 | id: version 21 | run: | 22 | echo "::set-output name=tag::$(git describe --abbrev=0 --tags)" 23 | # Build the plugin 24 | - name: Build 25 | id: build 26 | run: | 27 | npm install 28 | npm run build --if-present 29 | # Package the required files into a zip 30 | - name: Package 31 | run: | 32 | mkdir ${{ github.event.repository.name }} 33 | cp main.js manifest.json styles.css README.md ${{ github.event.repository.name }} 34 | zip -r ${{ github.event.repository.name }}.zip ${{ github.event.repository.name }} 35 | # Create the release on github 36 | - name: Create Release 37 | id: create_release 38 | uses: actions/create-release@v1 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | VERSION: ${{ github.ref }} 42 | with: 43 | tag_name: ${{ github.ref }} 44 | release_name: ${{ github.ref }} 45 | draft: false 46 | prerelease: false 47 | # Upload the packaged release file 48 | - name: Upload zip file 49 | id: upload-zip 50 | uses: actions/upload-release-asset@v1 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | with: 54 | upload_url: ${{ steps.create_release.outputs.upload_url }} 55 | asset_path: ./${{ github.event.repository.name }}.zip 56 | asset_name: ${{ github.event.repository.name }}-${{ steps.version.outputs.tag }}.zip 57 | asset_content_type: application/zip 58 | # Upload the main.js 59 | - name: Upload main.js 60 | id: upload-main 61 | uses: actions/upload-release-asset@v1 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | with: 65 | upload_url: ${{ steps.create_release.outputs.upload_url }} 66 | asset_path: ./main.js 67 | asset_name: main.js 68 | asset_content_type: text/javascript 69 | # Upload the manifest.json 70 | - name: Upload manifest.json 71 | id: upload-manifest 72 | uses: actions/upload-release-asset@v1 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | with: 76 | upload_url: ${{ steps.create_release.outputs.upload_url }} 77 | asset_path: ./manifest.json 78 | asset_name: manifest.json 79 | asset_content_type: application/json 80 | # Upload the style.css 81 | - name: Upload styles.css 82 | id: upload-css 83 | uses: actions/upload-release-asset@v1 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | with: 87 | upload_url: ${{ steps.create_release.outputs.upload_url }} 88 | asset_path: ./styles.css 89 | asset_name: styles.css 90 | asset_content_type: text/css 91 | # TODO: release notes??? -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /MSGraphPlugin.ts: -------------------------------------------------------------------------------- 1 | import { Editor, MarkdownView, ObsidianProtocolData, Plugin } from 'obsidian'; 2 | 3 | import { MSALAuthProvider, msalRedirect, refreshAllTokens } from 'authProvider'; 4 | import { Client } from '@microsoft/microsoft-graph-client'; 5 | 6 | 7 | import { MSGraphPluginSettings, DEFAULT_SETTINGS, MSGraphPluginSettingsTab } from 'msgraphPluginSettings'; 8 | import { MSGraphAccount } from 'types'; 9 | 10 | import { MailHandler } from 'mailHandler' 11 | 12 | 13 | import { CalendarHandler } from 'calendarHandler'; 14 | 15 | export default class MSGraphPlugin extends Plugin { 16 | settings: MSGraphPluginSettings; 17 | msalProviders: Record = {} 18 | graphClient: Client 19 | 20 | calendarHandler: CalendarHandler 21 | mailHandler: MailHandler 22 | 23 | authenticateAccount = (account: MSGraphAccount) => { 24 | const provider = new MSALAuthProvider(account) 25 | 26 | this.msalProviders[account.displayName] = provider 27 | 28 | return provider 29 | } 30 | 31 | getGraphClient = (msalProvider: MSALAuthProvider): Client => { 32 | return Client.initWithMiddleware({ 33 | debugLogging: true, 34 | authProvider: msalProvider 35 | }); 36 | } 37 | 38 | checkProvider = (displayName: string) => { 39 | return ( 40 | displayName in this.msalProviders && 41 | this.msalProviders[displayName].isInitialized() 42 | ) 43 | } 44 | 45 | async onload() { 46 | await this.loadSettings(); 47 | 48 | this.calendarHandler = new CalendarHandler(this) 49 | this.mailHandler = new MailHandler(this) 50 | 51 | this.registerObsidianProtocolHandler('msgraph', (query: ObsidianProtocolData) => {msalRedirect(this, query);}) 52 | 53 | // register all stored providers 54 | for (const account of this.settings.accounts) { 55 | if (account.displayName.trim() !== "" && account.enabled) { 56 | this.authenticateAccount(account) 57 | } 58 | } 59 | 60 | // Append today's events, sorted by start time 61 | this.addCommand({ 62 | id: 'append-todays-events-by-start', 63 | name: 'Append today\'s Events, sorted by start date', 64 | editorCallback: async (editor: Editor, view: MarkdownView) => { 65 | const result = this.calendarHandler.formatEvents(await this.calendarHandler.getEventsForToday()) 66 | 67 | editor.replaceSelection(result); 68 | } 69 | }); 70 | 71 | this.addCommand({ 72 | id: 'get-mails-from-all-folders', 73 | name: 'Append mails from all folders registered in the settings', 74 | editorCallback: async (editor: Editor, view: MarkdownView) => { 75 | const result = await this.mailHandler.formatMails(await this.mailHandler.getMailsForAllFolders(), false) 76 | 77 | editor.replaceSelection(result) 78 | } 79 | }) 80 | 81 | this.addCommand({ 82 | id: 'get-mails-from-all-folders-as-tasks', 83 | name: 'Append mails from all folders registered in the settings and format as tasks', 84 | editorCallback: async (editor: Editor, view: MarkdownView) => { 85 | const result = await this.mailHandler.formatMails(await this.mailHandler.getMailsForAllFolders(), true) 86 | 87 | editor.replaceSelection(result) 88 | } 89 | }) 90 | 91 | // This adds a settings tab so the user can configure various aspects of the plugin 92 | this.addSettingTab(new MSGraphPluginSettingsTab(this.app, this)); 93 | 94 | // Try to refresh the tokens every 30 minutes 95 | this.registerInterval( 96 | window.setInterval(() => refreshAllTokens(this), 1000 * 60 * 30) 97 | ) 98 | } 99 | 100 | onunload() { 101 | 102 | } 103 | 104 | async loadSettings() { 105 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 106 | } 107 | 108 | async saveSettings() { 109 | await this.saveData(this.settings); 110 | } 111 | } -------------------------------------------------------------------------------- /ObsidianTokenCachePlugin.ts: -------------------------------------------------------------------------------- 1 | import { TokenCacheContext, ICachePlugin } from "@azure/msal-common"; 2 | 3 | const { safeStorage } = require('@electron/remote') 4 | 5 | export const MSAL_ACCESS_TOKEN_LOCALSTORAGE_KEY_PREFIX = 'msal-tokencache-' 6 | 7 | export class ObsidianTokenCachePlugin implements ICachePlugin { 8 | 9 | public displayName: string 10 | private MSAL_ACCESS_TOKEN_LOCALSTORAGE_KEY: string 11 | public acquired: boolean 12 | 13 | constructor(displayName: string) { 14 | this.displayName = displayName 15 | this.MSAL_ACCESS_TOKEN_LOCALSTORAGE_KEY = MSAL_ACCESS_TOKEN_LOCALSTORAGE_KEY_PREFIX + this.displayName 16 | this.acquired = false 17 | } 18 | 19 | /** 20 | * Reads from local storage and decrypts. We don't care about efficiency here, otherwise 21 | * we could cache values in memory. But then we would have to prevent race conditions between 22 | * successive method calls, and this seems excessive for our use case. 23 | */ 24 | public async beforeCacheAccess(cacheContext: TokenCacheContext): Promise { 25 | //console.info("Executing before cache access"); 26 | 27 | const encryptedCache: string = localStorage.getItem(this.MSAL_ACCESS_TOKEN_LOCALSTORAGE_KEY) 28 | const cache = (encryptedCache !== null) 29 | ? safeStorage.decryptString(Buffer.from(encryptedCache, 'latin1')) 30 | : "" 31 | 32 | cacheContext.tokenCache.deserialize(cache); 33 | } 34 | 35 | /** 36 | * Encrypts and writes to local storage. 37 | */ 38 | public async afterCacheAccess(cacheContext: TokenCacheContext): Promise { 39 | //console.info("Executing after cache access"); 40 | 41 | if (cacheContext.cacheHasChanged) { 42 | const serializedAccounts = cacheContext.tokenCache.serialize() 43 | 44 | localStorage.setItem( 45 | this.MSAL_ACCESS_TOKEN_LOCALSTORAGE_KEY, 46 | safeStorage.encryptString(serializedAccounts).toString('latin1') 47 | ) 48 | } 49 | } 50 | 51 | /** Delete the token cache from local storage. 52 | */ 53 | public async deleteFromCache(): Promise { 54 | localStorage.removeItem(this.MSAL_ACCESS_TOKEN_LOCALSTORAGE_KEY) 55 | this.acquired = false 56 | } 57 | 58 | /** Has this cache been properly initialized? */ 59 | public isInitialized(): boolean { 60 | return (localStorage.getItem(this.MSAL_ACCESS_TOKEN_LOCALSTORAGE_KEY) !== null) 61 | } 62 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Microsoft Graph Plugin 2 | 3 | This plugin connects Obsidian (https://obsidian.md) to MS Graph, the central gateway to access and modify 4 | information stored in MS 365. 5 | 6 | Currently, the plugin allows to access to calendar items and emails. 7 | 8 | ### Installation 9 | 10 | Download the release zip-file and extract it inside the .obsidian/plugins - folder in your Vault. Then, activate the MSGraph 11 | plugin in the community plugins - section of the settings. 12 | 13 | ### Configuration 14 | 15 | To connect to MS Graph, the plugin needs to authenticate with an app registered via Microsoft Identity Platform. To register, either 16 | follow the instructions at https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app, or use the 17 | Azure command line interface (https://docs.microsoft.com/en-us/cli/azure/install-azure-cli). In any case, the app needs to be configured 18 | as a Single Page Application with the redirect URI "obsidian://msgraph". 19 | 20 | #### Using Azure portal 21 | 22 | In App registrations, click on your application, then Manage => Authentication => Platform configurations => Add a platform => Single-page application. 23 | Set the redirect URI to obsidian://msgraph. Write down the Application (client) ID in the App overview. 24 | 25 | If you want to use a confidential application, navigate to your application, then 26 | 27 | Certificates & secrets => Client secrets => New client secret. 28 | 29 | #### Using the Azure CLI 30 | 31 | Create the app with 32 | 33 | ```az ad app create --display-name obsidian-graph-client --reply-urls obsidian://msgraph/auth``` 34 | 35 | Optionally, you can add a client secret for a confidential application through the ```---password``` flag. 36 | 37 | Write down the appId. Then, configure the app as an SPA with 38 | 39 | ```az rest --method PATCH --uri 'https://graph.microsoft.com/v1.0/applications/{appID}' --headers 'Content-Type=application/json' --body "{spa:{redirectUris:['obsidian://msgraph']}}"``` 40 | 41 | where you replace {appId} with the value you wrote down in the last step. 42 | 43 | ### Configuring obsidian 44 | 45 | Once the application has been registered, you can configure the plugin in Obsidian. Activate the plugin and navigate to its settings. For each account you want to query, add an MSGraph account. 46 | The display name can be chosen freely, the Client ID corresponds to the appId you wrote down earlier. If you configured a password, use it as the client secret. The default for the authority field 47 | should work in most cases. Activate the account by switching the toggle to the right. 48 | 49 | If you want to access your email, configure Mail Folders in the following section of the settings dialog. Finally, if you want to fine-tune the rendering of events, mails, or tasks, you can change the 50 | Eta.js - templates in the settings. 51 | 52 | To use the plugin, open a note, open the Obsidian command palette (CTRL+p or CMD+p), and select one of the MSGraph - functions. Your browser will show a login window and ask for permission to access your data. 53 | 54 | 55 | ### Usage 56 | 57 | Currently, the plugin allows to 58 | 59 | - access all events for today from all calendars and write them to the current note 60 | - access all mails or all flagged mails inside the mail folders specified in the settings and write them to the current note, optionally formatted 61 | as tasks 62 | 63 | ### Example usage 64 | 65 | With the help of Templater (https://github.com/SilentVoid13/Templater), it is simple to add a list of today's events and flagged mails to the daily note. Simply add something like 66 | this 67 | 68 | ``` 69 | ## Meetings 70 | 71 | <% await this.app.plugins.plugins['obsidian-msgraph-plugin'].calendarHandler.formatEventsForToday() %> 72 | 73 | 74 | ### Overdue Emails 75 | 76 | <% await this.app.plugins.plugins['obsidian-msgraph-plugin'].mailHandler.formatOverdueMailsForAllFolders() %> 77 | ``` 78 | 79 | to your daily note template. 80 | 81 | -------------------------------------------------------------------------------- /authConfig.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel } from '@azure/msal-node'; 2 | 3 | export const redirectUri = 'obsidian://msgraph' 4 | export const msgraph_scopes = ["offline_access", "User.read", "Calendars.read", "Mail.read"] 5 | export const ews_scopes = (baseUri:string) => [`${baseUri}/EWS.AccessAsUser.All`, "offline_access"] 6 | 7 | export const requestConfig = { 8 | request: 9 | { 10 | authCodeUrlParameters: { 11 | scopes: msgraph_scopes, 12 | redirectUri: redirectUri, 13 | prompt: "select_account", 14 | }, 15 | tokenRequest: { 16 | code: "", 17 | redirectUri: redirectUri, 18 | scopes: msgraph_scopes, 19 | }, 20 | silentRequest: { 21 | scopes: msgraph_scopes, 22 | } 23 | }, 24 | }; 25 | 26 | export const ewsRequestConfig = (baseUri:string) => { 27 | return { 28 | request: 29 | { 30 | authCodeUrlParameters: { 31 | scopes: ews_scopes(baseUri), 32 | redirectUri: redirectUri, 33 | prompt: "select_account", 34 | }, 35 | tokenRequest: { 36 | code: "", 37 | redirectUri: redirectUri, 38 | scopes: ews_scopes(baseUri), 39 | }, 40 | silentRequest: { 41 | scopes: ews_scopes(baseUri), 42 | } 43 | }, 44 | } 45 | } -------------------------------------------------------------------------------- /authProvider.ts: -------------------------------------------------------------------------------- 1 | import { ClientApplication } from "@azure/msal-node"; 2 | //import { FilePersistenceWithDataProtection, DataProtectionScope } from "@azure/msal-node-extensions"; 3 | import { requestConfig, ewsRequestConfig } from 'authConfig' 4 | 5 | import { shell } from "electron"; 6 | import { PublicClientApplication } from '@azure/msal-node'; 7 | import { AuthenticationProvider } from '@microsoft/microsoft-graph-client'; 8 | import { CryptoProvider } from '@azure/msal-node'; 9 | import { AuthorizationCodeRequest } from '@azure/msal-node'; 10 | import { SilentFlowRequest } from "@azure/msal-node"; 11 | import { AuthenticationResult } from "@azure/msal-node"; 12 | import { PkceCodes } from "@azure/msal-common"; 13 | import { MSGraphAccount } from "types"; 14 | import MSGraphPlugin from "MSGraphPlugin"; 15 | import { ConfidentialClientApplication } from "@azure/msal-node"; 16 | import { ObsidianTokenCachePlugin } from "ObsidianTokenCachePlugin"; 17 | 18 | const { safeStorage } = require('@electron/remote') 19 | 20 | export const MSAL_ACCESS_TOKEN_LOCALSTORAGE_KEY = 'msal-access_token' 21 | export const MSGRAPH_ACCOUNTS_LOCALSTORAGE_KEY = 'msgraph-accounts' 22 | 23 | export class MSALAuthProvider implements AuthenticationProvider { 24 | // todo: somehow persist the tokens 25 | authConfig = { 26 | verifier: "", 27 | challenge: "", 28 | } 29 | msalClient: ClientApplication = null 30 | account: MSGraphAccount = null 31 | 32 | cachePlugin: ObsidianTokenCachePlugin 33 | 34 | constructor(account: MSGraphAccount) { 35 | this.account = account 36 | 37 | this.cachePlugin = new ObsidianTokenCachePlugin(account.displayName) 38 | if (account.clientSecret && account.clientSecret.trim()) { 39 | this.msalClient = new ConfidentialClientApplication({ 40 | auth: { 41 | clientId: account.clientId, 42 | clientSecret: account.clientSecret, 43 | authority: account.authority, 44 | }, 45 | cache: { 46 | cachePlugin: this.cachePlugin 47 | } 48 | }); 49 | } else { 50 | this.msalClient = new PublicClientApplication({ 51 | auth: { 52 | clientId: account.clientId, 53 | authority: account.authority, 54 | }, 55 | cache: { 56 | cachePlugin: this.cachePlugin 57 | } 58 | }); 59 | } 60 | 61 | const cryptoProvider = new CryptoProvider(); 62 | cryptoProvider.generatePkceCodes() 63 | .then((codes: PkceCodes) => { 64 | this.authConfig.challenge = codes.challenge 65 | this.authConfig.verifier = codes.verifier 66 | }) 67 | } 68 | 69 | removeAccessToken = async () => { 70 | this.cachePlugin.deleteFromCache() 71 | } 72 | 73 | isInitialized = (): boolean => { 74 | return this.cachePlugin.isInitialized() 75 | } 76 | 77 | getTokenSilently = async (): Promise => { 78 | // retrieve all cached accounts 79 | const accounts = await this.msalClient.getTokenCache().getAllAccounts(); 80 | 81 | if (accounts.length > 0) { 82 | // todo: logic to choose the correct account 83 | // for now, just use the first one 84 | const account = accounts[0] 85 | 86 | const config = this.account.type == "MSGraph" ? requestConfig : ewsRequestConfig(this.account.baseUri); 87 | const silentRequest: SilentFlowRequest = { 88 | ...config.request.silentRequest, 89 | account: account 90 | } 91 | 92 | return this.msalClient.acquireTokenSilent(silentRequest) 93 | .then((authResponse: AuthenticationResult) => { 94 | return authResponse.accessToken 95 | }) 96 | .then((accessToken: string) => { 97 | console.info("Successfully obtained access token from cache!") 98 | return accessToken 99 | }) 100 | .catch((error: unknown) => { 101 | return "" 102 | }) 103 | } else { 104 | return "" 105 | } 106 | } 107 | 108 | /** 109 | * This method will get called before every request to the msgraph server 110 | * This should return a Promise that resolves to an accessToken (in case of success) or rejects with error (in case of failure) 111 | * Basically this method will contain the implementation for getting and refreshing accessTokens 112 | */ 113 | getAccessToken = async () => { 114 | const access_token = await this.getTokenSilently() 115 | 116 | if (access_token !== "") { 117 | return access_token 118 | } else { 119 | msalLogin(this) 120 | 121 | let total_waiting_time = 0 122 | const max_waiting_time = 60000 // 1 minute 123 | const ms = 500 124 | 125 | while (this.cachePlugin.acquired == false && total_waiting_time <= max_waiting_time) { 126 | await new Promise(resolve => { 127 | setTimeout(resolve, ms) 128 | }) 129 | total_waiting_time += ms 130 | } 131 | 132 | if (this.cachePlugin.acquired == false) { 133 | console.log("Could not acquire token!") 134 | return "" 135 | } else { 136 | console.info("Successfully logged in!") 137 | return await this.getTokenSilently() 138 | } 139 | } 140 | } 141 | } 142 | 143 | export function msalLogin(msalProvider: MSALAuthProvider) { 144 | const pkceCodes = { 145 | challengeMethod: "S256", // Use SHA256 Algorithm 146 | verifier: msalProvider.authConfig.verifier, 147 | challenge: msalProvider.authConfig.challenge 148 | }; 149 | 150 | const config = msalProvider.account.type == "MSGraph" ? requestConfig : ewsRequestConfig(msalProvider.account.baseUri); 151 | 152 | const authCodeUrlParams = { 153 | ...config.request.authCodeUrlParameters, // redirectUri, scopes 154 | state: msalProvider.account.displayName, 155 | codeChallenge: pkceCodes.challenge, // PKCE Code Challenge 156 | codeChallengeMethod: pkceCodes.challengeMethod, // PKCE Code Challenge Method 157 | }; 158 | 159 | 160 | msalProvider.msalClient.getAuthCodeUrl(authCodeUrlParams) 161 | .then((response:any) => { 162 | shell.openExternal(response); 163 | }) 164 | .catch((error:any) => console.log(JSON.stringify(error))); 165 | } 166 | 167 | export function msalRedirect(plugin: MSGraphPlugin, query: any) { 168 | const displayName = query.state; 169 | 170 | if (!(displayName in plugin.msalProviders)) { 171 | console.error("Invalid auth request: unknown account!") 172 | return 173 | } 174 | 175 | const authProvider = plugin.msalProviders[displayName] 176 | 177 | const config = authProvider.account.type == "MSGraph" ? requestConfig : ewsRequestConfig(authProvider.account.baseUri); 178 | 179 | // Add PKCE code verifier to token request object 180 | const tokenRequest: AuthorizationCodeRequest = { 181 | ...config.request.tokenRequest, 182 | code: query.code as string, 183 | codeVerifier: authProvider.authConfig.verifier, // PKCE Code Verifier 184 | clientInfo: query.client_info as string 185 | }; 186 | 187 | authProvider.msalClient.acquireTokenByCode(tokenRequest).then((response: AuthenticationResult) => { 188 | authProvider.cachePlugin.acquired = true 189 | }).catch((error: unknown) => { 190 | console.log(error) 191 | authProvider.cachePlugin.acquired = false 192 | }) 193 | } 194 | 195 | export async function refreshAllTokens(plugin: MSGraphPlugin) { 196 | await Promise.all(Object.values(plugin.msalProviders).map( 197 | async (provider) => {if (provider.account.enabled) provider.getTokenSilently()} 198 | )) 199 | } -------------------------------------------------------------------------------- /calendarHandler.ts: -------------------------------------------------------------------------------- 1 | import MSGraphPlugin from "MSGraphPlugin"; 2 | import { htmlToMarkdown } from "obsidian"; 3 | import { EventWithProvider } from "types"; 4 | 5 | import { Event } from '@microsoft/microsoft-graph-types' 6 | 7 | import { DateTime } from "luxon"; 8 | 9 | // @ts-ignore 10 | import * as Eta from './node_modules/eta/dist/browser.module.mjs' 11 | import { AppointmentSchema, BasePropertySet, CalendarFolder, CalendarView, DateTime as EWSDateTime, EmailMessageSchema, EwsLogging, ExchangeService, FolderId, ItemView, Mailbox, OAuthCredentials, PropertySet, Uri, WellKnownFolderName } from "ews-javascript-api" 12 | 13 | export class CalendarHandler { 14 | plugin:MSGraphPlugin 15 | eta:Eta.Eta 16 | 17 | constructor(plugin:MSGraphPlugin) { 18 | this.plugin = plugin 19 | this.eta = new Eta.Eta() 20 | } 21 | 22 | getEventsForTimeRange = async (start:DateTime, end:DateTime):Promise> => { 23 | const events_by_provider:Record = {} 24 | 25 | // todo: fetch in parallel using Promise.all()? 26 | for (const authProviderName in this.plugin.msalProviders) { 27 | const authProvider = this.plugin.msalProviders[authProviderName] 28 | 29 | if (authProvider.account.type == "MSGraph") { 30 | const graphClient = this.plugin.getGraphClient(authProvider) 31 | 32 | const dateQuery = `startDateTime=${encodeURIComponent(start.toISO())}&endDateTime=${encodeURIComponent(end.toISO())}`; 33 | 34 | const events = await graphClient 35 | .api('/me/calendarView').query(dateQuery) 36 | //.select('subject,start,end') 37 | .orderby(`start/DateTime`) 38 | .get(); 39 | 40 | events_by_provider[authProviderName] = events.value 41 | } else if (authProvider.account.type == "EWS") { 42 | const authProvider = this.plugin.msalProviders[authProviderName] 43 | 44 | const token = await authProvider.getAccessToken() 45 | 46 | const svc = new ExchangeService(); 47 | //EwsLogging.DebugLogEnabled = true; // false to turnoff debugging. 48 | svc.Url = new Uri(`${authProvider.account.baseUri}/EWS/Exchange.asmx`); 49 | 50 | svc.Credentials = new OAuthCredentials(token); 51 | 52 | // Initialize values for the start and end times, and the number of appointments to retrieve. 53 | 54 | // Initialize the calendar folder object with only the folder ID. 55 | const calendar = await CalendarFolder.Bind(svc, WellKnownFolderName.Calendar, new PropertySet()); 56 | 57 | // Set the start and end time and number of appointments to retrieve. 58 | const calendarView = new CalendarView(new EWSDateTime(start.toMillis()), new EWSDateTime(end.toMillis())); 59 | 60 | // Limit the properties returned to the appointment's subject, start time, and end time. 61 | calendarView.PropertySet = new PropertySet(AppointmentSchema.Subject, AppointmentSchema.Start, AppointmentSchema.End, AppointmentSchema.IsAllDayEvent); 62 | // Retrieve a collection of appointments by using the calendar view. 63 | const appointments = await calendar.FindAppointments(calendarView); 64 | 65 | const results = appointments.Items.map(({ Subject, Start, End, IsAllDayEvent }) => ( 66 | { 67 | subject: Subject, 68 | start: {dateTime: Start.ToISOString()}, 69 | end: {dateTime: End.ToISOString()}, 70 | isAllDay: IsAllDayEvent 71 | } as Event)) as [Event]; 72 | 73 | events_by_provider[authProviderName] = results 74 | } 75 | } 76 | 77 | return events_by_provider 78 | } 79 | 80 | getEventsForToday = async () => { 81 | const today = DateTime.now().startOf('day') 82 | const tomorrow = DateTime.now().endOf('day') 83 | 84 | return await this.getEventsForTimeRange(today, tomorrow) 85 | } 86 | 87 | flattenAndSortEventsByStartTime = (events_by_provider:Record) => { 88 | const result:Array = [] 89 | for (const provider in events_by_provider) { 90 | for (const event of events_by_provider[provider]) { 91 | event.subject = htmlToMarkdown(event.subject) 92 | result.push({...event, provider: provider}) 93 | } 94 | } 95 | 96 | result.sort((a,b):number => { 97 | const at = DateTime.fromISO(a.start.dateTime, {zone: a.start.timeZone}) 98 | const bt = DateTime.fromISO(b.start.dateTime, {zone: b.start.timeZone}) 99 | 100 | return at.toMillis() - bt.toMillis() 101 | }) 102 | 103 | return result 104 | } 105 | 106 | formatEvents = (events:Record):string => { 107 | let result = "" 108 | 109 | const flat_events = this.flattenAndSortEventsByStartTime(events) 110 | 111 | try { 112 | for (const e of flat_events) { 113 | console.log(e) 114 | result += this.eta.renderString(this.plugin.settings.eventTemplate, {...e, luxon:DateTime }) + "\n\n" 115 | } 116 | } catch (e) { 117 | console.error(e) 118 | } 119 | 120 | return result 121 | } 122 | 123 | formatEventsForToday = async () => { 124 | return this.formatEvents(await this.getEventsForToday()) 125 | } 126 | } -------------------------------------------------------------------------------- /defaultEventTemplate.ts: -------------------------------------------------------------------------------- 1 | export const defaultEventTemplate = 2 | `<% 3 | formatTime = (dt) => { 4 | return moment.utc(dt).local().locale(navigator.language).format("LT") 5 | } 6 | %> 7 | 8 | <% if (it.isAllDay) { %> 9 | ### All day: <%~ it.subject %> 10 | <% } else { %> 11 | ### <%= formatTime(it.start.dateTime) %>-<%= formatTime(it.end.dateTime) %>: <%~ it.subject %> 12 | <% } %>` -------------------------------------------------------------------------------- /defaultFlaggedMailTemplate.ts: -------------------------------------------------------------------------------- 1 | export const defaultFlaggedMailTemplate = 2 | `<% 3 | formatMailAddress = (ma) => { 4 | return ma.name + " <" + ma.address + ">" 5 | } 6 | 7 | formatDateTime = (dt) => { 8 | return moment.utc(dt).local().locale(navigator.language).toLocaleString([]) 9 | } 10 | 11 | formatBody = (body) => { 12 | return body 13 | .split(/\\r?\\n/) 14 | .map(s => "\\t" + s) 15 | .join("\\n") 16 | } 17 | %> 18 | <% if ('flag' in it) { %> 19 | - [<%~ (it.flag.flagStatus === 'complete') ? 'x' : ' ' %>] <%~ (it.flag.dueDateTime) ? '📅' + formatDateTime(it.flag.dueDateTime.dateTime) : '' %> <%~ it.subject %> [src](<%~ it.webLink %>) 20 | <%~ formatBody(it.bodyPreview) %> 21 | <% } else { %> 22 | - [ ] <%~ it.subject %> [src](<%~ it.webLink %>) 23 | <%~ formatBody(it.bodyPreview) %> 24 | <% } %> 25 | ` -------------------------------------------------------------------------------- /defaultMailTemplate.ts: -------------------------------------------------------------------------------- 1 | export const defaultMailTemplate = 2 | `<% 3 | formatMailAddress = (ma) => { 4 | return ma.name + " <" + ma.address + ">" 5 | } 6 | 7 | formatDateTime = (dt) => { 8 | return moment.utc(dt).local().locale(navigator.language).toLocaleString([]) 9 | } 10 | 11 | formatBody = (body) => { 12 | return body 13 | .split(/\\r?\\n/) 14 | .map(s => "> " + s) 15 | .join("\\n") 16 | } 17 | %> 18 | > [!NOTE]- <%~ it.subject %> 19 | > #### From: <%~ formatMailAddress(it.from.emailAddress) %> 20 | > #### Date: <%~ formatDateTime(it.receivedDateTime) %> 21 | > #### Body: <%~ formatBody(it.bodyPreview) %> 22 | ` -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules' 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === 'production'); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['MSGraphPlugin.ts'], 19 | bundle: true, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/closebrackets', 25 | '@codemirror/collab', 26 | '@codemirror/commands', 27 | '@codemirror/comment', 28 | '@codemirror/fold', 29 | '@codemirror/gutter', 30 | '@codemirror/highlight', 31 | '@codemirror/history', 32 | '@codemirror/language', 33 | '@codemirror/lint', 34 | '@codemirror/matchbrackets', 35 | '@codemirror/panel', 36 | '@codemirror/rangeset', 37 | '@codemirror/rectangular-selection', 38 | '@codemirror/search', 39 | '@codemirror/state', 40 | '@codemirror/stream-parser', 41 | '@codemirror/text', 42 | '@codemirror/tooltip', 43 | '@codemirror/view', 44 | ...builtins], 45 | format: 'cjs', 46 | target: 'es2018', 47 | logLevel: "debug", 48 | sourcemap: prod ? false : 'inline', 49 | treeShaking: true, 50 | outfile: 'main.js', 51 | }); 52 | 53 | if (prod) { 54 | await context.rebuild(); 55 | process.exit(0); 56 | } else { 57 | await context.watch(); 58 | } 59 | -------------------------------------------------------------------------------- /mailHandler.ts: -------------------------------------------------------------------------------- 1 | import { MailFolder, Message, OutlookItem } from "@microsoft/microsoft-graph-types" 2 | import { MSALAuthProvider } from "authProvider" 3 | import MSGraphPlugin from "MSGraphPlugin" 4 | import { SelectMailFolderModal } from "selectMailFolderModal" 5 | 6 | // @ts-ignore 7 | import * as Eta from './node_modules/eta/dist/browser.module.mjs' 8 | 9 | import { MSGraphMailFolderAccess } from "types" 10 | import moment from "moment" 11 | 12 | 13 | export class MailHandler { 14 | plugin:MSGraphPlugin 15 | eta:Eta.Eta 16 | 17 | constructor(plugin:MSGraphPlugin) { 18 | this.plugin = plugin 19 | this.eta = new Eta.Eta() 20 | } 21 | 22 | getMailFolders = async (authProvider: MSALAuthProvider, limit=250):Promise => { 23 | // this could be so much simpler, if we wouldn't support self-hosted exchange installations... 24 | // otherwise, we could just query https://graph.microsoft.com/v1.0/me/mailfolders/delta?$select=displayname 25 | // and would be done... 26 | const graphClient = this.plugin.getGraphClient(authProvider) 27 | 28 | const getChildFoldersRecursive = async (fs:Array): Promise => { 29 | let result:Array = [] 30 | 31 | for (const f of fs) { 32 | const request = graphClient 33 | .api(`/me/mailfolders/${f.id}/childFolders?includeHiddenFolders=true`) 34 | .select("displayName,childFolderCount") 35 | .top(limit) 36 | 37 | const childFolders = (await request.get()).value 38 | 39 | result = result.concat(childFolders) 40 | 41 | const remainingChildFolders = childFolders.filter((f:MailFolder) => f.childFolderCount > 0) 42 | const remainingGrandChildren = await getChildFoldersRecursive(remainingChildFolders) 43 | 44 | result = result.concat(remainingChildFolders).concat(remainingGrandChildren) 45 | } 46 | 47 | return result 48 | } 49 | 50 | let request = graphClient 51 | .api("/me/mailfolders?includeHiddenFolders=true") 52 | .select("displayName,childFolderCount") 53 | .top(limit) 54 | 55 | let root_folders = (await request.get()).value 56 | 57 | root_folders = root_folders.concat(await getChildFoldersRecursive(root_folders.filter((f:MailFolder) => f.childFolderCount > 0))) 58 | 59 | request = graphClient 60 | .api("/me/mailFolders/searchfolders/childFolders") 61 | .select("displayName,childFolderCount") 62 | .top(limit) 63 | 64 | root_folders = root_folders.concat((await request.get()).value) 65 | 66 | return root_folders 67 | } 68 | 69 | selectMailFolder = async (account:string) => { 70 | const selector = new SelectMailFolderModal(this.plugin.app, this.plugin) 71 | 72 | const folders = await this.getMailFolders(this.plugin.msalProviders[account]) 73 | selector.setFolders(folders) 74 | selector.open() 75 | } 76 | 77 | getMailsForFolder = async (mf: MSGraphMailFolderAccess) => { 78 | const authProvider = this.plugin.msalProviders[mf.provider] 79 | 80 | const graphClient = this.plugin.getGraphClient(authProvider) 81 | 82 | let request = graphClient.api(`/me/mailFolders/${mf.id}/messages`) 83 | 84 | if (mf.query !== undefined) 85 | request = request.query(mf.query) 86 | 87 | if (mf.limit !== undefined) 88 | request = request.top(mf.limit) 89 | 90 | if (mf.onlyFlagged) 91 | request = request.filter('flag/flagStatus eq \'flagged\'') 92 | 93 | return (await request.get()).value 94 | } 95 | 96 | getMailsForAllFolders = async () => { 97 | const mails:Record = {} 98 | for (const mf of this.plugin.settings.mailFolders) { 99 | mails[mf.displayName] = await this.getMailsForFolder(mf) 100 | } 101 | 102 | return mails 103 | } 104 | 105 | formatMails = (mails:Record, as_tasks=false):string => { 106 | let result = "" 107 | 108 | for (const folder in mails) { 109 | result += `# ${folder}\n\n` 110 | 111 | for (const m of mails[folder]) { 112 | result += this.eta.renderString(as_tasks 113 | ? this.plugin.settings.flaggedMailTemplate 114 | : this.plugin.settings.mailTemplate, m) + "\n\n" 115 | } 116 | 117 | result += "\n" 118 | } 119 | 120 | return result 121 | } 122 | 123 | formatMailsForAllFolders = async (as_tasks=false): Promise => { 124 | return this.formatMails(await this.getMailsForAllFolders(), as_tasks) 125 | } 126 | 127 | dueFilter = (m:Message) => { 128 | return (m?.flag?.dueDateTime?.dateTime !== undefined) 129 | ? moment.utc(m.flag.dueDateTime.dateTime) <= moment() 130 | : true 131 | } 132 | 133 | formatOverdueMailsForAllFolders = async (): Promise => { 134 | const ms = await this.getMailsForAllFolders() 135 | 136 | for (const folder in ms) { 137 | if (ms[folder].length > 0) { 138 | ms[folder] = ms[folder].filter(this.dueFilter) 139 | } 140 | } 141 | 142 | return this.formatMails(ms, true) 143 | } 144 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-msgraph-plugin", 3 | "name": "MSGraph Plugin", 4 | "version": "0.9.9", 5 | "minAppVersion": "1.8.0", 6 | "description": "This plugin allows to query MSGraph from obsidian.", 7 | "author": "Andreas Hildebrandt", 8 | "authorUrl": "https://github.com/anhi", 9 | "isDesktopOnly": true 10 | } -------------------------------------------------------------------------------- /msgraphPluginSettings.ts: -------------------------------------------------------------------------------- 1 | 2 | import { App, ButtonComponent, Setting } from 'obsidian' 3 | import { PluginSettingTab } from 'obsidian' 4 | import MSGraphPlugin from './MSGraphPlugin' 5 | 6 | import { MSGraphAccount, MSGraphMailFolderAccess } from 'types'; 7 | import { SelectMailFolderModal } from 'selectMailFolderModal'; 8 | import { MailFolder } from '@microsoft/microsoft-graph-types'; 9 | 10 | import { defaultEventTemplate } from "./defaultEventTemplate" 11 | import { defaultMailTemplate } from "./defaultMailTemplate" 12 | import { defaultFlaggedMailTemplate } from "./defaultFlaggedMailTemplate" 13 | 14 | export interface MSGraphPluginSettings { 15 | accounts: Array, 16 | mailFolders: Array, 17 | eventTemplate: string, 18 | mailTemplate: string, 19 | flaggedMailTemplate: string, 20 | } 21 | 22 | export const DEFAULT_SETTINGS: MSGraphPluginSettings = { 23 | accounts: [new MSGraphAccount()], 24 | mailFolders: [new MSGraphMailFolderAccess()], 25 | eventTemplate: defaultEventTemplate, 26 | mailTemplate: defaultMailTemplate, 27 | flaggedMailTemplate: defaultFlaggedMailTemplate, 28 | } 29 | 30 | export class MSGraphPluginSettingsTab extends PluginSettingTab { 31 | plugin: MSGraphPlugin; 32 | 33 | constructor(app: App, plugin: MSGraphPlugin) { 34 | super(app, plugin); 35 | this.plugin = plugin; 36 | } 37 | 38 | display(): void { 39 | const {containerEl} = this; 40 | 41 | containerEl.empty(); 42 | 43 | containerEl.createEl('h2', {text: 'Settings for MSGraphPlugin.'}); 44 | 45 | this.add_account_setting() 46 | 47 | this.add_mail_settings() 48 | 49 | this.containerEl.createEl("h2", { text: "Templates" }); 50 | const descHeading = document.createDocumentFragment(); 51 | descHeading.append( 52 | "Templates for converting MSGraph objects to Markdown." 53 | ); 54 | new Setting(this.containerEl).setDesc(descHeading); 55 | 56 | this.add_event_template() 57 | this.add_mail_template() 58 | } 59 | 60 | add_account_setting(): void { 61 | this.containerEl.createEl("h2", { text: "MSGraph Access" }); 62 | 63 | const descHeading = document.createDocumentFragment(); 64 | descHeading.append( 65 | "Each account you want to query MSGraph with needs to be authorized." 66 | ); 67 | 68 | new Setting(this.containerEl).setDesc(descHeading); 69 | 70 | new Setting(this.containerEl) 71 | .setName("Add Account") 72 | .setDesc("Add MSGraph Account") 73 | .addButton((button: ButtonComponent) => { 74 | button 75 | .setTooltip("Add additional MSGraph account") 76 | .setButtonText("+") 77 | .setCta() 78 | .onClick(() => { 79 | this.plugin.settings.accounts.push(new MSGraphAccount()); 80 | this.plugin.saveSettings(); 81 | this.display(); 82 | }); 83 | }); 84 | 85 | this.plugin.settings.accounts.forEach( 86 | (account, index) => { 87 | const div = this.containerEl.createEl("div"); 88 | div.addClass("msgraph_div"); 89 | 90 | const title = this.containerEl.createEl("h4", { 91 | text: "Account #" + index, 92 | }); 93 | title.addClass("msgraph_title"); 94 | 95 | const s = new Setting(this.containerEl) 96 | .addExtraButton((extra) => { 97 | extra 98 | .setIcon("cross") 99 | .setTooltip("Delete") 100 | .onClick(() => { 101 | this.plugin.settings.accounts.splice( 102 | index, 103 | 1 104 | ); 105 | this.plugin.saveSettings(); 106 | // Force refresh 107 | this.display(); 108 | }); 109 | }) 110 | .addText((text) => { 111 | const t = text 112 | .setPlaceholder("Display Name") 113 | .setValue(account.displayName) 114 | .onChange((new_value) => { 115 | this.plugin.settings.accounts[index].displayName = new_value; 116 | this.plugin.saveSettings(); 117 | }); 118 | t.inputEl.addClass("msgraph_display_name"); 119 | 120 | return t; 121 | }) 122 | .addText((text) => { 123 | const t = text 124 | .setPlaceholder("Client Id") 125 | .setValue(account.clientId) 126 | .onChange((new_value) => { 127 | this.plugin.settings.accounts[index].clientId = new_value; 128 | this.plugin.saveSettings(); 129 | }); 130 | t.inputEl.addClass("msgraph_client_id"); 131 | 132 | return t; 133 | }) 134 | .addText((text) => { 135 | const t = text 136 | .setPlaceholder("Client Secret (optional)") 137 | .setValue(account.clientSecret) 138 | .onChange((new_value) => { 139 | this.plugin.settings.accounts[index].clientSecret = new_value; 140 | this.plugin.saveSettings(); 141 | }); 142 | t.inputEl.addClass("msgraph_client_secret"); 143 | 144 | return t; 145 | }) 146 | .addText((text) => { 147 | const t = text 148 | .setPlaceholder("authority") 149 | .setValue(account.authority) 150 | .onChange((new_value) => { 151 | this.plugin.settings.accounts[index].authority = new_value; 152 | this.plugin.saveSettings(); 153 | }); 154 | t.inputEl.addClass("msgraph_authority"); 155 | 156 | return t; 157 | }) 158 | .addToggle((toggle) => { 159 | toggle.setValue(this.plugin.checkProvider(this.plugin.settings.accounts[index].displayName)) 160 | .setTooltip("Authorize account") 161 | .onChange((new_value) => { 162 | const account = this.plugin.settings.accounts[index] 163 | 164 | if (new_value) { 165 | this.plugin.authenticateAccount(account) 166 | account.enabled = true 167 | } else { 168 | // todo: de-authorize token 169 | this.plugin.msalProviders[account.displayName].removeAccessToken() 170 | delete this.plugin.msalProviders[account.displayName] 171 | account.enabled = false 172 | } 173 | }) 174 | }) 175 | .addDropdown((dropdown) => { 176 | dropdown.addOptions( 177 | {MSGraph: "MSGraph", EWS: "EWS"} 178 | ) 179 | .setValue(this.plugin.settings.accounts[index].type) 180 | .onChange((new_value) => { 181 | this.plugin.settings.accounts[index].type = new_value 182 | this.plugin.saveSettings() 183 | }) 184 | 185 | dropdown.selectEl.addClass("msgraph_provider_type") 186 | }) 187 | .addText((text) => { 188 | const t = text 189 | .setPlaceholder("EWS URL") 190 | .setValue(account.baseUri) 191 | .onChange((new_value) => { 192 | this.plugin.settings.accounts[index].baseUri = new_value; 193 | this.plugin.saveSettings(); 194 | }); 195 | t.inputEl.addClass("msgraph_baseuri"); 196 | 197 | return t; 198 | }) 199 | 200 | s.infoEl.remove(); 201 | 202 | div.appendChild(title); 203 | div.appendChild(this.containerEl.lastChild); 204 | } 205 | ); 206 | } 207 | 208 | add_mail_settings = (): void => { 209 | this.containerEl.createEl("h2", { text: "Mail related settings" }); 210 | 211 | const descHeading = document.createDocumentFragment(); 212 | descHeading.append( 213 | "Mail folders you want to access" 214 | ); 215 | 216 | new Setting(this.containerEl).setDesc(descHeading); 217 | 218 | new Setting(this.containerEl) 219 | .setName("Add Mail Folder") 220 | .addButton((button: ButtonComponent) => { 221 | button 222 | .setTooltip("Add additional mail folder") 223 | .setButtonText("+") 224 | .setCta() 225 | .onClick(() => { 226 | this.plugin.settings.mailFolders.push(new MSGraphMailFolderAccess()); 227 | this.plugin.saveSettings(); 228 | this.display(); 229 | }); 230 | }); 231 | 232 | this.plugin.settings.mailFolders.forEach( 233 | (folder, index) => { 234 | const div = this.containerEl.createEl("div"); 235 | div.addClass("msgraph_div"); 236 | 237 | const s = new Setting(this.containerEl) 238 | .addExtraButton((extra) => { 239 | extra 240 | .setIcon("cross") 241 | .setTooltip("Delete") 242 | .onClick(() => { 243 | this.plugin.settings.mailFolders.splice( 244 | index, 245 | 1 246 | ); 247 | this.plugin.saveSettings(); 248 | // Force refresh 249 | this.display(); 250 | }); 251 | }) 252 | .addText((text) => { 253 | const t = text 254 | .setPlaceholder("Display Name") 255 | .setValue(folder.displayName) 256 | .onChange((new_value) => { 257 | folder.displayName = new_value; 258 | this.plugin.saveSettings(); 259 | }); 260 | t.inputEl.addClass("msgraph_display_name"); 261 | 262 | return t; 263 | }) 264 | .addDropdown((dropdown) => { 265 | const options: Record = {invalid: "Choose Provider"} 266 | 267 | for (const account of this.plugin.settings.accounts) { 268 | options[account.displayName] = account.displayName 269 | } 270 | const d = dropdown 271 | .addOptions(options) 272 | .setValue(folder.provider) 273 | .onChange((new_value) => { 274 | folder.provider = new_value 275 | this.plugin.saveSettings() 276 | // Force refresh 277 | this.display(); 278 | }); 279 | d.selectEl.addClass("msgraph_input") 280 | }) 281 | .addText((text) => { 282 | text 283 | .setPlaceholder("") 284 | .setValue(folder.id) 285 | .onChange((new_value) => { 286 | folder.id = new_value 287 | this.plugin.saveSettings() 288 | // Force refresh 289 | this.display(); 290 | }); 291 | }) 292 | .addButton((button) => { 293 | button 294 | .setButtonText("Browse") 295 | .setTooltip("Select search folder") 296 | .onClick(() => { 297 | if (this.plugin.checkProvider(folder.provider)) { 298 | const modal = new SelectMailFolderModal(this.app, this.plugin) 299 | modal.onChooseItem = (f:MailFolder, evt:Event) => { 300 | folder.id = f.id 301 | this.plugin.saveSettings() 302 | this.display() 303 | } 304 | 305 | this.plugin.mailHandler 306 | .getMailFolders(this.plugin.msalProviders[folder.provider]) 307 | .then((folders) => modal.setFolders(folders)) 308 | .then(() => modal.open()) 309 | 310 | } 311 | }) 312 | }) 313 | .addText((text) => { 314 | const t = text 315 | .setPlaceholder("Maximum number of mails to return") 316 | .setValue(folder.limit.toString()) 317 | .onChange((new_value) => { 318 | const new_limit = parseInt(new_value, 10) 319 | if (!isNaN(new_limit)) { 320 | folder.limit = new_limit 321 | this.plugin.saveSettings() 322 | } else { 323 | this.display() 324 | } 325 | }) 326 | t.inputEl.addClass("msgraph-number") 327 | }) 328 | .addDropdown((dropdown) => { 329 | dropdown 330 | .addOptions({all: "All emails", flagged: "Only flagged emails"}) 331 | .setValue(folder.onlyFlagged ? 'flagged' : 'all') 332 | .onChange((new_value) => { 333 | folder.onlyFlagged = new_value === 'flagged' 334 | this.plugin.saveSettings() 335 | }) 336 | 337 | dropdown.selectEl.addClass("msgraph_input") 338 | } 339 | ) 340 | 341 | s.infoEl.remove(); 342 | 343 | div.appendChild(this.containerEl.lastChild); 344 | }); 345 | 346 | } 347 | 348 | add_event_template = (): void => { 349 | new Setting(this.containerEl) 350 | .setName("Event Template") 351 | .setDesc("Template for rendering Events") 352 | .addButton((button) => { 353 | button 354 | .setButtonText("Default") 355 | .setTooltip("Restore default template") 356 | .onClick(() => { 357 | this.plugin.settings.eventTemplate = defaultEventTemplate 358 | this.plugin.saveSettings(); 359 | // Force refresh 360 | this.display(); 361 | }); 362 | }) 363 | .addTextArea((text) => { 364 | const t = text 365 | .setPlaceholder("Template Text") 366 | .setValue(this.plugin.settings.eventTemplate) 367 | .onChange((new_value) => { 368 | this.plugin.settings.eventTemplate = new_value 369 | this.plugin.saveSettings() 370 | }) 371 | t.inputEl.addClass("msgraph_template") 372 | }) 373 | } 374 | 375 | add_mail_template = (): void => { 376 | new Setting(this.containerEl) 377 | .setName("Mail Template") 378 | .setDesc("Template for rendering Mail items") 379 | .addButton((button) => { 380 | button 381 | .setButtonText("Default") 382 | .setTooltip("Restore default template") 383 | .onClick(() => { 384 | this.plugin.settings.mailTemplate = defaultMailTemplate 385 | this.plugin.saveSettings(); 386 | // Force refresh 387 | this.display(); 388 | }); 389 | }) 390 | .addTextArea((text) => { 391 | const t = text 392 | .setPlaceholder("Template Text") 393 | .setValue(this.plugin.settings.mailTemplate) 394 | .onChange((new_value) => { 395 | this.plugin.settings.mailTemplate = new_value 396 | this.plugin.saveSettings() 397 | }) 398 | t.inputEl.addClass("msgraph_template") 399 | }) 400 | 401 | new Setting(this.containerEl) 402 | .setName("Flagged mail template") 403 | .setDesc("Template for rendering flagged mail items as tasks") 404 | .addButton((button) => { 405 | button 406 | .setButtonText("Default") 407 | .setTooltip("Restore default template") 408 | .onClick(() => { 409 | this.plugin.settings.flaggedMailTemplate = defaultFlaggedMailTemplate 410 | this.plugin.saveSettings(); 411 | // Force refresh 412 | this.display(); 413 | }); 414 | }) 415 | .addTextArea((text) => { 416 | const t = text 417 | .setPlaceholder("Template Text") 418 | .setValue(this.plugin.settings.flaggedMailTemplate) 419 | .onChange((new_value) => { 420 | this.plugin.settings.flaggedMailTemplate = new_value 421 | this.plugin.saveSettings() 422 | }) 423 | t.inputEl.addClass("msgraph_template") 424 | }) 425 | 426 | } 427 | 428 | } 429 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-msgraph-plugin", 3 | "version": "0.9.9", 4 | "description": "This plugin allows to query MSGraph from obsidian.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "cd node_modules/fetch && npm i && cd ../../ && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "postinstall": "patch-package" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@microsoft/microsoft-graph-types": "^2.38.0", 17 | "@types/luxon": "^3.3.1", 18 | "@types/node": "^20.5.0", 19 | "@typescript-eslint/eslint-plugin": "^6.3.0", 20 | "@typescript-eslint/parser": "^6.3.0", 21 | "builtin-modules": "^3.3.0", 22 | "electron": "^25.5.0", 23 | "esbuild": "0.19.2", 24 | "obsidian": "latest", 25 | "patch-package": "^8.0.0", 26 | "tslib": "2.6.1", 27 | "typescript": "5.1.6" 28 | }, 29 | "dependencies": { 30 | "@azure/identity": "^3.3.0", 31 | "@azure/msal-node": "^2.0.1", 32 | "@azure/msal-node-extensions": "^1.0.1", 33 | "@electron/remote": "^2.0.10", 34 | "@microsoft/microsoft-graph-client": "^3.0.5", 35 | "eta": "^3.1.0", 36 | "ews-javascript-api": "^0.12.0", 37 | "iconv-lite": "^0.6.2", 38 | "luxon": "^3.4.0", 39 | "fetch-ah": "git://git@github.com/anhi/andris9_fetch.git" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /patches/fetch+1.1.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/fetch/package.json b/node_modules/fetch/package.json 2 | index d9698ad..665d173 100644 3 | --- a/node_modules/fetch/package.json 4 | +++ b/node_modules/fetch/package.json 5 | @@ -15,7 +15,7 @@ 6 | "license": "MIT", 7 | "dependencies": { 8 | "biskviit": "1.0.1", 9 | - "encoding": "0.1.12" 10 | + "encoding": "0.1.13" 11 | }, 12 | "devDependencies": { 13 | "chai": "^3.5.0", 14 | -------------------------------------------------------------------------------- /selectMailFolderModal.ts: -------------------------------------------------------------------------------- 1 | import { MailFolder } from "@microsoft/microsoft-graph-types"; 2 | import MSGraphPlugin from "MSGraphPlugin"; 3 | import { App, FuzzySuggestModal, Notice } from "obsidian"; 4 | 5 | export class SelectMailFolderModal extends FuzzySuggestModal { 6 | app:App 7 | plugin:MSGraphPlugin 8 | folders:MailFolder[] 9 | 10 | constructor(app:App, plugin:MSGraphPlugin) { 11 | super(app); 12 | 13 | this.app = app 14 | this.plugin = plugin 15 | } 16 | 17 | setFolders = (folders:MailFolder[]) => { 18 | this.folders = folders 19 | } 20 | 21 | getItems(): MailFolder[] { 22 | return this.folders 23 | } 24 | 25 | getItemText(f: MailFolder): string { 26 | return f.displayName; 27 | } 28 | 29 | onChooseItem(f: MailFolder, evt: MouseEvent | KeyboardEvent) { 30 | new Notice(`Selected ${f.displayName}`); 31 | } 32 | } -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .msgraph_div { 2 | border-top: 1px solid var(--background-modifier-border); 3 | } 4 | 5 | .msgraph_div > .setting-item { 6 | border-top: none !important; 7 | align-self: center; 8 | } 9 | 10 | .msgraph_div > .setting-item > .setting-item-control { 11 | justify-content: space-around; 12 | padding: 0; 13 | width: 100%; 14 | } 15 | 16 | .msgraph_div 17 | > .setting-item 18 | > .setting-item-control 19 | > .setting-editor-extra-setting-button { 20 | align-self: center; 21 | } 22 | 23 | .msgraph_title { 24 | margin: 0; 25 | padding: 0; 26 | margin-top: 5px; 27 | text-align: center; 28 | } 29 | 30 | .msgraph_display_name { 31 | align-self: center; 32 | margin-left: 5px; 33 | margin-right: 5px; 34 | width: 20%; 35 | } 36 | 37 | .msgraph_client_id { 38 | align-self: center; 39 | margin-left: 5px; 40 | margin-right: 5px; 41 | width: 20%; 42 | } 43 | 44 | .msgraph_client_secret { 45 | align-self: center; 46 | margin-left: 5px; 47 | margin-right: 5px; 48 | width: 20%; 49 | } 50 | 51 | .msgraph_authority { 52 | align-self: center; 53 | margin-left: 5px; 54 | margin-right: 5px; 55 | width: 20%; 56 | } 57 | 58 | .msgraph_baseuri { 59 | align-self: center; 60 | margin-left: 5px; 61 | margin-right: 5px; 62 | width: 20%; 63 | } 64 | 65 | .msgraph_template { 66 | align-self: center; 67 | margin-left: 5px; 68 | margin-right: 5px; 69 | width: 50em; 70 | height: 7em; 71 | } 72 | 73 | .msgraph_input { 74 | align-self: center; 75 | margin-left: 5px; 76 | margin-right: 5px; 77 | width: 20%; 78 | } 79 | 80 | .msgraph_provider_type { 81 | align-self: center; 82 | margin-left: 5px; 83 | margin-right: 5px; 84 | width: 15% !important; 85 | } 86 | 87 | .msgraph_number { 88 | align-self: center; 89 | margin-left: 5px; 90 | margin-right: 5px; 91 | width: 10%; 92 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "esModuleInterop": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import * as MicrosoftGraph from "@microsoft/microsoft-graph-types" 2 | 3 | export type CachedToken = { 4 | displayName: string, 5 | accessToken: string, 6 | } 7 | 8 | export class MSGraphAccount { 9 | displayName = "" 10 | clientId = "" 11 | clientSecret = "" 12 | authority = "https://login.microsoftonline.com/common" 13 | enabled = false 14 | type = "MSGraph" 15 | baseUri = "" 16 | } 17 | 18 | export type EventWithProvider = MicrosoftGraph.Event & {provider: string} 19 | 20 | export class MSGraphMailFolderAccess { 21 | displayName = "" 22 | provider = "" 23 | id = "" 24 | limit = 100 25 | query = "" 26 | onlyFlagged = false 27 | } -------------------------------------------------------------------------------- /update_reply_urls.ps1: -------------------------------------------------------------------------------- 1 | az rest --method PATCH --uri 'https://graph.microsoft.com/v1.0/applications/XXX-XXXXXXXXX' --headers 'Content-Type=application/json' --body "{spa:{redirectUris:['obsidian://msgraph']}}" -------------------------------------------------------------------------------- /util.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon" 2 | 3 | export const formatTime = (date:Date): string => { 4 | const dt = DateTime.fromJSDate(date) 5 | return dt.toLocaleString(DateTime.TIME_SIMPLE) 6 | } -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.9.1": "0.12.0", 3 | "0.9.2": "0.12.0", 4 | "0.9.3": "0.12.0", 5 | "0.9.4": "0.12.0", 6 | "0.9.5": "0.12.0", 7 | "0.9.6": "0.12.0", 8 | "0.9.7": "0.12.0", 9 | "0.9.8": "0.12.0", 10 | "0.9.9": "0.12.0" 11 | } --------------------------------------------------------------------------------