Demystifying Mail.app Plugins – A Tutorial
In the course of writing my email un-attachment plugin for Mail.app, I found that Apple has a capable, but entirely undocumented, plugin API. I’m providing this tutorial in the hopes that it may be useful to anyone else considering implementing a plugin for Apple Mail.
Update 2008-02-16: See my updated version of this tutorial for Leopard.
Update 2006-04-11: Apparently yesterday’s update truncated the entry. I’ve managed to restore it thanks to the Google Gods and Their Glorious Cache.
Update 2006-04-10: Added some cautionary text about using a private API, as suggested by Jens Alfke. It’s all common sense, but definitely deserves a mention. Especially considering how uncommon common sense is!
We’ll see how to examine Mail’s private API, load a plugin into Mail, and use that plugin to extend Mail’s behavior. This tutorial uses PyObjC, the Python<->Objective-C bridge. You could just as easily use Objective-C, but I prefer to work in Python. The PyObjC folks have done a wonderful job and deserve a lot of credit. Just remember that an Objective-C message of the form doSomethingWithAnObject: obj1 andAnother: obj2 gets translated into PyObjC as doSomethingWithAnObject_andAnother_(obj1, obj2) There should be one underscore for each colon. Otherwise, it’s pretty much the same as in Objective-C.
Examining a Private API
Mail.app uses a private plugin API. The fact that this is a private API should immediately raise some red flags. Apple has made no commitment to keeping this API intact or functioning consistently from release to release. Updates to Mail (even within major and minor releases) may cause this interface to break or even to disappear entirely. As a result, treat any use of the plugin API as a hack, and be defensive in your coding.
From a practical perspective, the fact that the plugin API is private, means that the API isn’t published. As a result, the easiest way to figure it out is to look at a class-dump, which shows the listing of the various classes and method names in the application. Here is a class-dump of Mail 2.0.5 that I used. Alternatively, you can generate your own with Steve Nygard’s excellent class-dump utility by running class-dump /Applications/Mail.app in Terminal.
The class we’re looking for is MVMailBundle. The MVMailBundle is our hook into the Mail application. Let’s take a look at the methods it defines:
+ (id)allBundles; + (id)composeAccessoryViewOwners; + (void)registerBundle; + (id)sharedInstance; + (BOOL)hasPreferencesPanel; + (id)preferencesOwnerClassName; + (id)preferencesPanelName; + (BOOL)hasComposeAccessoryViewOwner; + (id)composeAccessoryViewOwnerClassName; - (void)dealloc; - (void)_registerBundleForNotifications;
Loading a Plugin
Of particular interest to us is +registerBundle. This is a class-method that we’ll need to call in order to register our bundle with Mail. To do that, we’ll need to use the +initialize method. This message is sent when the Objective-C runtime loads the class. We can just use that message to register the bundle when it is loaded. Let’s create a file, MyPlugin.py, to do that:
from AppKit import *
from Foundation import *
import objc
MVMailBundle = objc.lookUpClass('MVMailBundle')
class MyPlugin(MVMailBundle):
def initialize (cls):
super(MyPlugin, cls).initialize()
super(MyPlugin, cls).registerBundle()
NSLog("MyPlugin registered with Mail")
initialize = classmethod(initialize)
The first three lines are our standard PyObjC boiler-plate to load Cocoa’s Application Kit and Foundation frameworks, and the objc runtime module. The next line is a little trickier. MVMailBundle is defined in a private framework for which we don’t have a python wrapper. If we were to simply refer to it as an ordinary class, we’d get an error, since it hasn’t been defined in any of the modules we’ve loaded. We’ll first query the Objective-C runtime and save the returned class.
Next, we define our initialize method as described above. It just registers our plugin class with Mail, and drops a line to the Console for debugging purposes.
Building our Plugin
Let’s build our plugin and make sure it works so far. It doesn’t do anything, but we can at least make sure it gets loaded. First, we need to define a simple setup.py file to build the plugin. It should look like this:
from distutils.core import setup
import py2app
plist = dict(NSPrincipalClass='MyPlugin')
setup(
plugin = ['MyPlugin.py'],
options=dict(py2app=dict(extension='.mailbundle', plist=plist))
)
PyObjC uses py2app to build application and plugin bundles on OS X. The file we wrote above will work, but you can see the py2app documentation for more information about writing a setup.py file for py2app.
We need only run python setup.py py2app -A to build our mailbundle. The -A option to setup.py builds an alias build. This links the built bundle against the python files we used when we built. That way, we won’t need to rebuild the bundle each time we make a change. If everything went well, there should be a file, MyPlugin.mailbundle in a new dist folder in the current directory. Let’s take this opportunity to quit Mail if it’s running. We’ll also move the bundle to ~/Library/Mail/Bundles/. You might need to create it if it does not already exist.
Although Mail includes support for Mail bundles out of the box, it is disabled by default. To enable it, we need edit Mail’s defaults:
% defaults write com.apple.mail EnableBundles -bool true % defaults write com.apple.mail BundleCompatibilityVersion 2
Now Mail will load any .mailbundles when it loads. Let’s give it a try. First, open /Applications/Utilities/Console.app so we can see any output. Now launch Mail. If all went well, we should see a line similar to the following:
2006-03-28 22:52:34.240 Mail[2983] MyPlugin registered with Mail
Extending the Editor Window
Great! That means that Mail has seen the mail bundle and has loaded it. Let’s make it actually do something now. Let’s add a check for unattached attachments. When we send a message, we want to check if there are any attachments. If there aren’t, we’ll perform a rudimentary check to see if the message body contains any text that seems to refer to an attachment. To do that, we’ll need to intercept the send command.
If we look in the class-dump, we’ll find an entry for a WebMessageEditor class. That’s the class that defines Mail’s message editor UI. Of particular interest to us is the -send: message. This message is an IBAction that gets called whenever the user presses the Send button on the toolbar, selects the Send menu item, or uses the command shortcut. We’ll just extend this class and overload this method:
class MyWebMessageEditor(WebMessageEditor):
__slots__ = () # This will be important later!
def send_(self, sender):
NSLog('Trying to send something with MyPlugin!')
super(MyWebMessageEditor, self).send_(sender)
Now our plugin should intercept the send operation and log something to the console before sending the message. If we restart Mail and try sending ourselves a message, we’ll find the message goes through, but nothing appears on the console. The problem is that Mail doesn’t know anything about MyWebMessageEditor. When the user creates a new email message, Mail just instantiates a new WebMessageEditor.
Objective-C’s class posing mechanism will be useful here. Objective-C lets a subclass take over its parent class as long as the parent has never received any message, and the child class does not define any new data members. That’s why we needed the __slots__ = () line. Otherwise, PyObjC would have added some bookkeeping data members to the class.
Let’s add the following line to our plugin’s initialize method, just after our call to registerBundle:
MyWebMessageEditor.poseAsClass_(WebMessageEditor)
Now, whenever Mail instantiates a WebMessageEditor, it will actually be instantiating MyWebMessageEditor. Cool beans! Furthermore, class posing doesn’t change the class hierarchy. Thus, super will still let us refer to our parent, WebMessageEditor.
With all this in place, we should be able to restart Mail and try sending a message again. This time, we’ll see our message in the console.
Putting Everything Together
Now we just need to perform our actual attachment checks. Here’s that code:
import re
import traceback
ATTACH_EXP_STR = r'battach(?:ment|ments|ing|ed)?b'
ATTACH_EXP = re.compile(ATTACH_EXP_STR, re.I)
class MyWebMessageEditor(WebMessageEditor):
__slots__ = ()
def send_(self, sender):
shouldSend = True
try:
attachments = self.attachments()
if attachments is not None and len(attachments) > 0:
# Message has attachment(s); no need to check.
pass
else:
message = self.backEnd().message()
body = message.messageBody()
data = unicode(body.rawData())
for line in data.splitlines():
if line.lstrip().startswith('>'): continue # Ignore quoted replies
if ATTACH_EXP.search(line):
# Message claims to have an attachment, but we didn't find any!
shouldSend = False
self.showAttachmentAlertSheet()
break
except Exception, e:
NSLog("Trouble scanning outgoing message for attachments: %s: %s" % (e.__class__, e))
traceback.print_exc() # for debugging
if shouldSend:
super(MyWebMessageEditor, self).send_(sender)
# Alert UI methods
def showAttachmentAlertSheet(self):
alert = NSAlert.alloc().init()
alert.addButtonWithTitle_('Send')
alert.addButtonWithTitle_('Cancel')
alert.setMessageText_('Message Has No Attachment')
alert.setInformativeText_("Your mail appears to refer to an attachment, "
"but none exists. Do you wish to continue?")
alert.beginSheetModalForWindow_modalDelegate_didEndSelector_contextInfo_(
self.window(), self, self.attachmentAlertSheetDidEnd, 0)
def attachmentAlertSheetDidEnd(self, panel, returnCode, contextInfo):
if returnCode == NSAlertFirstButtonReturn:
super(MyWebMessageEditor, self).send_(contextInfo)
else:
NSLog('User canceled sending message without attachment.')
attachmentAlertSheetDidEnd = PyObjCTools.AppHelper.endSheetMethod(attachmentAlertSheetDidEnd)
You can refer to my version of MyPlugin.py and setup.py in case of any trouble. Now, when we restart Mail, if we compose a message to ourselves with a body of “I’m attaching the latest TPS report” (or something similar), you should see an alert sheet when you hit send. Send or cancel, it will do what you expect, as long as you expect “Send” to send the message and “Cancel” not to.
There’s a lot more that you can do. For example, I’ve glossed over some of Mail’s internal classes that we use in the example. They’re all listed in the class-dump. You could also inject a python interpreter to play around further, if you were so inclined. But I’ll leave that to another posting.
Neal Said,
April 5, 2006 @ 10:16 am
Hi James,
Great plugin! Can you make this a universal app?
Berko Said,
April 5, 2006 @ 2:05 pm
Hey, James, thanks a bunch for this plugin. I have been plagued by this for years! Once I am further along in my degree, I think I will revisit this tut on plugin dev. Thanks again!
James Eagan Said,
April 5, 2006 @ 3:00 pm
Neal: I doubt I’ll be able to make a universal version any time soon, as I don’t have any access to an Intel Mac. I’ll see what I can do, though!
Jason Gutierrez Said,
April 5, 2006 @ 3:39 pm
Very cool. Got your tip from the OSXHints site. This bundle should be part of the app itself. Great job! I’m no programmer so thank you for supplying the compiled bundle.
Saptarshi Guha Said,
April 7, 2006 @ 1:32 pm
Hi,
A big thank you for the detailed tutorial. Was trying to figure out Mail.App plugins for sometime and then gave up.
But then I learnt objective c and got a hold on that.
With this tutorial, I’ll try again.
Thanks
Saptarshi
Ben Said,
April 7, 2006 @ 3:24 pm
Is there any way to modify the message body? I’m having trouble with that.
Ben Said,
April 7, 2006 @ 3:51 pm
Oops, hit submit too soon I guess. Anyway, I got the message body’s rawData(), and I can see it has an appendString_() method, but that doesn’t seem to do anything.
Saptarshi Said,
April 8, 2006 @ 12:56 am
Hi,
I;m trying to modify the signature of my email. I see a method in the class
WebMessageEditor - updateContentsToShowSignature and method in the class WebComposeBackEnd - setSignature.
But I would like to see the signature - which variable or function returns the current one…
Any ideas?
Thanks
Saptarshi Said,
April 8, 2006 @ 2:01 am
Aah, got it! In fact the variable in setSignature of WebComposeBackEnd is of class Signature… and then i can use the ‘textvalue’ to see it
Thanks for the great tutorial
eaganj Said,
April 8, 2006 @ 1:11 pm
Hi Ben,
I haven’t played at all with actually changing the message body. You might wish to take a look at GPGMail and how it does it, since it, presumably, has to replace the message body with its signed or encrypted representation.
My guess is that it might be handled somewhere in the MessageTextStorage, but I really don’t know. You’ll have to poke around with the class-dump and just see what works.
Cheers!
James
Non Stop Mac Said,
April 9, 2006 @ 5:15 pm
Demystifying Mail.app plugins – a tutorial…
James Eagan writes: “In the course of writing my email un-attachment plugin for Mail.app, I found that Apple has a capable, but entirely undocumented, plugin API. I’m providing this tutorial in the hopes that it may be useful to anyone……
Will Said,
April 9, 2006 @ 6:39 pm
This tutorial is fantastic, except that the plugin you created solves the problem I needed a tutorial for in the first place. I’m not sure what to make of that. The internet is a strange place. But thanks!!
eaganj Said,
April 10, 2006 @ 8:42 am
Will: Whaddya know. It is a small world after all!
Jens Alfke Said,
April 10, 2006 @ 10:19 am
Mail plug-ins can be great; I use several of them myself. But I feel I should offer some Important Safety Tips:
Be aware that the plug-in API is unsupported, and the internal classes of Mail are likely to change in minor or major ways from one OS release (or even software update) to the next, possibly causing any Mail plug-in either to fail to load, or to make Mail behave incorrectly.
(Those who remember the joys of classic Mac OS system extensions and their conflicts will know what I’m talking about here!)
Developers who write these plug-ins should program defensively, like making sure that Mail classes and methods are available before they try to call them (or at least wrapping try/catch blocks around their code to handle failures) to minimize the damage caused by future incompatibilities.
Also, please use prefixes on all class and method names to avoid conflicts with both Mail and other plugins that might be active.
Finally, be aware that, if you’re going to continue to support your plug-in, you should pay close attention to software updates and be ready to debug and update your software as soon as one’s released!