https://tritium.legal/blog/outlook Tritium | Logo About Blog Docs Download * Outlook * Jane * Glitch * Update * Eat * Word * Tokio * I18n * Drive COM Like a Bomb: the Rust Outlook Add-in by Drew Miller on 2025-12-10 One of legal tech's cliches is that "lawyers live in Word". This is demonstrably incorrect. I, for example, am a lawyer and in fact live in London, England. But what they mean to say is that lawyers spend much of their time editing documents in Microsoft Word. This is because, for the most part, opening .docx files in Word is the default behavior where it's installed (everywhere). Lawyers, and again I'm speaking from experience here, are generally lazy when it comes to technology. Defaults are the law. This is rational. Clients pay thousands of dollars per hour to have their legal needs addressed by the top law firms in the world. This means that law firms account for every moment their lawyers' working days. Generally, in 6-minute increments (or, 0.1 hours). No client is paying even 0.3 for their lawyer to learn a new software paradigm, and most law firms don't find forgoing revenue to train lawyers on new systems that will make them faster especially motivating. So to get a foothold into legal, we need to make Tritium slot as nearly as possible into the existing workflow. So where does the legal work flow originate? Three places: (1) the document management system (DMS), (2) the desktop and (3) email. We've previously talked about iManage, one of the most important document management systems in legal. There are other important ones such as NetDocuments, and our integrations into those will be the subject of another post. Today, we're focused on the third place. We're giving access to Tritium right in the lawyer's inbox. We're going to replicate our "Open with Tritium" desktop entry point in Outlook. Here's what it looks like on the desktop: [open-with-] Outlook Integration "New Outlook" is some sort of half-implemented WebView mess that requires javascript round-tripped from a host server to plug in new features. We'll eventually have to get in there, too, but for the most part law firms seem to have thus far stuck with the much more featureful "legacy Outlook". That version is a venerable, performant, C++-based Windows desktop application. [venerable-] So, how do we plug into it? COM Before even the easy 100 MB of RAM days let alone the advent of node and electron and JSON, the Windows operating system needed a way to allow processes and applications to communicate in a language-agnostic way. This ultimately resulted in the "Component Object Model" or COM. COM allows us to plug into various entry points using a Dynamically Linked Library (.dll) which follows a strict ABI with certain calling conventions. COM lives on today, and it is still an effective way to communicate with various processes, including Windows 11's File Explorer. Fortunately, COM is supported in the windows-rs Rust crate.[1] To add a link to Outlook's attachment context menu, we need to inherit from a series of COM classes: IDispatch, IDTExtensibility2 and ultimately IRibbonExtensibility. windows-rs provides an IDispatch implementation out-of-the box which exposes a trait that looks like the below: fn GetTypeInfoCount(&self) -> windows::core::Result {} fn GetIDsOfNames( &self, riid: *const GUID, rgsz_names: *const PCWSTR, c_names: u32, lcid: u32, rg_disp_id: *mut i32, ) -> std::result::Result<(), windows_core::Error> {} fn Invoke( &self, disp_id_member: i32, riid: *const GUID, lcid: u32, w_flags: DISPATCH_FLAGS, p_disp_params: *const DISPPARAMS, p_var_result: *mut VARIANT, p_excep_info: *mut EXCEPINFO, pu_arg_err: *mut u32, ) -> std::result::Result<(), windows_core::Error> {} These functions provide the basic COM dispatching mechanisms. Using them a caller is able to look up the rg_disp_id of a particular named function in your implementation, then Invoke that function with the results optionally populating p_var_result which is a pointer to a mutable union of possible result types. This is the basic wiring which allows us to implement the required IDTExensibility2 and IRibbonExtensibility classes. windows-rs doesn't implement these classes, but does help us by providing the interface procedural macro which handles setting up the VTables to map our struct's methods to the COM ABI. We use the class's GUID for the macro to establish that we're implementing IDTExtensibility2.[2] #[windows::core::interface("B65AD801-ABAF-11D0-BB8B-00A0C90F2744")] pub unsafe trait IDTExtensibility2: IDispatch { unsafe fn OnConnection( &self, _application: Option<&IDispatch>, _connectmode: i32, _addin_instance: Option<&IDispatch>, _custom: SAFEARRAY, ) -> HRESULT; unsafe fn OnDisconnection(&self, mode: i32, custom: SAFEARRAY) -> HRESULT; unsafe fn OnAddInsUpdate(&self, custom: SAFEARRAY) -> HRESULT; unsafe fn OnStartupComplete(&self, custom: SAFEARRAY) -> HRESULT; unsafe fn OnBeginShutdown(&self, custom: SAFEARRAY) -> HRESULT; } Then, we implement that interface for our struct. #[implement(IRibbonExtensibility, IDTExtensibility2, IDispatch)] struct Addin; This causes the procedural macro to generate IRibbonExensibility_Impl, IDTExensibility2_Impl and IDispatch_Impl traits for us to implement in struct Addin_Impl. Here's the initial Tritium IDTExensibility2_Impl verbatim for example: impl IDTExtensibility2_Impl for Addin_Impl { unsafe fn OnConnection( &self, _application: Option<&IDispatch>, _connectmode: i32, _addin_instance: Option<&IDispatch>, _custom: SAFEARRAY, ) -> HRESULT { log("OnConnection called()"); // Don't do any heavy operations here that could crash Outlook S_OK } unsafe fn OnDisconnection(&self, _mode: i32, _custom: SAFEARRAY) -> HRESULT { log("OnDisconnection called()"); S_OK } unsafe fn OnAddInsUpdate(&self, _custom: SAFEARRAY) -> HRESULT { log("OnAddInsUpdate called()"); S_OK } unsafe fn OnStartupComplete(&self, _custom: SAFEARRAY) -> HRESULT { log("OnStartupComplete called()"); S_OK } unsafe fn OnBeginShutdown(&self, _custom: SAFEARRAY) -> HRESULT { log("OnBeginShutdown called()"); S_OK } } As discussed below, we used an LLM to generate these signatures since they aren't provided in the windows-rs crate out of the box. Since our simple add-in at this point doesn't maintain any global state that would otherwise be constructed, adjusted and deconstructed at OnConnection, OnAddInsUpdate and OnBeginShutdown, respectively, we just log the call for debugging and return S_OK. Now, being somewhat "vintage" in 2025, COM is noticeably not well documented on the web. For example, Microsoft's own web documentation for the IRibbonExtensibility class in C++ gently nudges one towards the managed C# version: [iribbonext] But from this we can determine that GetCustomUI is called with an id string, which is used to look up the correct custom XML ribbon we've implemented. That is returned to the caller. In our case, that's Outlook. That's helpful for understanding the mechanics, but not exactly helpful for implementing the API in Rust. In fact, despite many minutes of bona fide web searching, I was unable to locate the C++ signature for IRibbonExtensibility. But, it's 2025 and since modern LLMs have ingested and essentially compressed the entire web, plus all books and New York Times articles ever written, we can ask them to generate a signature for IRibbonExtensibility for us! This is what Claude one-shotted at the time: impl IRibbonExtensibility_Impl for Addin { unsafe fn GetCustomUI(&self, _ribbon_id: BSTR, xml: *mut BSTR) -> HRESULT { // Only provide ribbon XML for specific ribbon IDs or all if we want global // ribbon For now, we'll provide it for all requests unsafe { *xml = BSTR::from(RIBBON_XML); } S_OK } } So, unlike the C# code which returns our custom XML, C++ and, thus the Rust implementation, wants an HRESULT value to specify success and the result written to a mutable parameter called xml here. Seems plausible. Rust would do this more ergonomically with the Result return type today, but this is a common historical approach. And with that, we implement a custom RIBBON_XML, which looks like this: const RIBBON_XML: &str = r#"