Demystifying Mail.app Plugins for Leopard
In the course of writing my email un-attachment plugin for Mail.app (and subsequently updating it for Leopard), I found that Apple has a capable, but entirely undocumented, plugin API. I’m providing this update to my previous tutorial in the hopes that it may be useful to anyone else considering implementing a plugin for Apple Mail.
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 3 that I used. 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):
MVMailBundle.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 3
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:
2/11/08 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 MailDocumentEditor 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:
MailDocumentEditor = objc.lookUpClass("MailDocumentEditor")
class MyMessageEditor(MailDocumentEditor):
__slots__ = () # This will be important later!
def send_(self, sender):
NSLog('Trying to send something with MyPlugin!')
super(MyMessageEditor, 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 MyMessageEditor. When the user creates a new email message, Mail just instantiates a new MailDocumentEditor.
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:
MyMessageEditor.poseAsClass_(MailDocumentEditor)
Now, whenever Mail instantiates a MailDocumentEditor, it will actually be instantiating MyMessageEditor. Cool beans! Furthermore, class posing doesn’t change the class hierarchy. Thus, super will still let us refer to our parent, MailDocumentEditor.
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 = ur'\battach(?:ment|ments|ing|ed)?\b'
ATTACH_EXP = re.compile(ATTACH_EXP_STR, re.I)
HTML_QUOTE_STR = ur'<BLOCKQUOTE(?:\s|=)+type=3D"cite">.*?</BLOCKQUOTE>'
HTML_QUOTE_EXP = re.compile(HTML_QUOTE_STR, re.I|re.M|re.S)
class MyMessageEditor(MailDocumentEditor):
__slots__ = ()
def send_(self, sender):
shouldSend = True
try:
attachments = self.backEnd().attachments()
if attachments is not None and len(attachments) > 0:
# Message has attachment(s); no need to check.
pass
else:
message = self.backEnd().message()
data = message.rawSource()
data = HTML_QUOTE_EXP.sub(u'', data) # Ignore HTML quoted replies
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("[ASP] Trouble scanning outgoing message for attachments: %s: %s" % (e.__class__, e))
traceback.print_exc()
if shouldSend:
super(MyMessageEditor, self).send_(sender)
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(MyMessageEditor, self).send_(contextInfo)
else:
NSLog(u'[ASP] 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), we should see an alert sheet when we 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.
Vlajbert Said,
February 17, 2008 @ 4:22 pm
I’m getting ‘ImportError: No module named objc’. Any ideas why this would happen?
James Eagan Said,
February 17, 2008 @ 4:49 pm
@Vlajbert: That’s interesting. Leopard comes with PyObjC pre-installed, so you shouldn’t actually need to install anything, unless you aren’t using the System Python. Have you installed Python yourself? Also, if you aren’t using Leopard, you’ll need to install PyObjC yourself and refer to the Tiger version of this tutorial.
Vlajbert Said,
February 17, 2008 @ 9:03 pm
Hey James, thanks for the fast response. Well, coming from the Linux world, our dev team was moved to Mac’s 4 months ago, I assumed I had install everything.
So I installed a 2.5 version of py2app and setuptools. Doops, shot myself in the foot. When I nuked the stuff I installed into /Library/Python/2.5/site-packages your demo worked fine.
Now I’m scanning through class-dump looking for some kind of open-mail event. The plan is to parse out Exchange meeting invites and inject them into iCal.
rpechayr Said,
February 21, 2008 @ 7:52 am
Congratulation for plugin and the tutorial ! My question is why shouldn’t it work with leopard on PPCs ?
I am planning to do a similar plugin and it looks like there is something I am missing . Thanks a lot
Stefan Said,
February 21, 2008 @ 10:50 am
Hello!
First of all, thank you for the tutorial! Is there a way to use Objective-C instead of PyObjC?
Best regards, Stefan
James Eagan Said,
February 21, 2008 @ 2:37 pm
@rpechayr: It should work for Leopard running on a PPC. Are you having trouble getting it to work in such an environment?
James Eagan Said,
February 21, 2008 @ 2:43 pm
@Stefan: There’s really no reason you can’t use Objective-C. I just used Python via PyObjC because I wrote the tutorial after some code I had written in Python. The fact that the tutorial uses Python really shouldn’t matter much, though.
Update: It looks like markdown gobbled the part of my post that discusses PyObjC. I’ve since fixed that.
rpechayr Said,
February 23, 2008 @ 10:36 am
It works, I had to recompile it but now it works, thank you very much
Stefan Said,
February 27, 2008 @ 7:16 am
@James: Thank you
rpechayr Said,
February 29, 2008 @ 5:20 am
I am trying to make this work with Objective-C and I am completely stuck at the beginning because I can’t figure out what the equivalent of objc.lokkUpClass in pure Objective-C would be. I looked for details about this function on the pyobj website and I didn’t find anything.
Normally I need an interface for all the classes I use from the API.
Does anyone have a clue ?
Thanks a lot
Olof Said,
March 8, 2008 @ 7:50 am
Thanks a thousands! This was really great!
Introducing the Apple Mail StupidFilter for Leopard | timothytwillman.com Said,
March 11, 2008 @ 9:09 pm
[…] someone else’s code to do what i want, since it was easier to use code James Eagan wrote an Apple Mail plugin (and very helpful docs) that handled much of the basic Mail interface I […]
Peter Said,
March 21, 2008 @ 3:03 pm
NSObject +(void)poseAsClass: (Class)aClass
unfortunately this has been depreciated. since 10.5 and is not available with 64bit apps.
CDS Said,
March 26, 2008 @ 3:53 am
A fantastically useful little plugin. One of the many things (another being Quicksilver) that Apple should be acquiring and including in the OS.
Have you considered adding an option within the dialog box to locate and attach the missing file?
Max Said,
March 26, 2008 @ 5:36 pm
Compiling with “-A” (for alias) works, but the resulting
plugin is only some KB in size and will not work if copied
to the apropriate bundle folder.
However, such a “-A”-Mailbundle works, if the binary
of the ASP which is available for download is put
in the bundle folder. Obviously, because of the “-A”,
some files are missing which come with the binary.
Can anyone help me to find out, how I can create
a plugin, that can work on its own (without the binary
that is available for download here) added to the
bundles folder? Thanks!
James Eagan Said,
March 26, 2008 @ 5:56 pm
@Peter: Ah, you’re right. You could use a category to replace a method of an existing class, but that doesn’t help use here, since we want to be able to call its supermethod. Thus, it looks like this approach will no longer work under 64-bit machines. I’ll have to see what other options I can find for this use-case under such systems.
James Eagan Said,
March 26, 2008 @ 5:58 pm
@Max: I wasn’t sufficiently clear about the -A option to py2app. It should continue to work if you move the bundle on the same machine (that’s how I use it), but it should never be used on other machines. For that, just omit the -A to build a standalone copy that embeds the python interpreter and all necessary libraries.
Max Said,
March 27, 2008 @ 1:50 am
When I omit the “-A” compiling does not work.
With your example source files, I get the following
error messages:
running py2app
*** filtering dependencies ***
365 total
361 filtered
1 orphaned
4 remaining
*** create binaries ***
*** byte compile python files ***
skipping byte-compilation of /Library/Python/2.5/site-packages/py2app-0.3.6-py2.5.egg/py2app/bootstrap/boot_plugin.py to boot_plugin.pyc
skipping byte-compilation of /Library/Python/2.5/site-packages/py2app-0.3.6-py2.5.egg/py2app/bootstrap/disable_linecache.py to disable_linecache.pyc
Traceback (most recent call last):
File “/Library/Python/2.5/site-packages/py2app-0.3.6-py2.5.egg/py2app/build_app.py”, line 548, in _run
self.run_normal()
File “/Library/Python/2.5/site-packages/py2app-0.3.6-py2.5.egg/py2app/build_app.py”, line 619, in run_normal
self.create_binaries(py_files, pkgdirs, extensions, loader_files)
File “/Library/Python/2.5/site-packages/py2app-0.3.6-py2.5.egg/py2app/build_app.py”, line 683, in create_binaries
dry_run=self.dry_run)
File “/Library/Python/2.5/site-packages/py2app-0.3.6-py2.5.egg/py2app/util.py”, line 204, in byte_compile
if force or newer(mod.filename, cfile):
File “/System/Library/Frameworks/Python.framework/Versions/2.5/lib/python2.5/distutils/dep_util.py”, line 22, in newer
raise DistutilsFileError, “file ‘%s’ does not exist” % source
DistutilsFileError: file ‘/Library/Python/2.5/site-packages/setuptools-0.6c7-py2.5.egg/pkg_resources.pyc’ does not exist
> /System/Library/Frameworks/Python.framework/Versions/2.5/lib/python2.5/distutils/dep_util.py(22)newer()
-> raise DistutilsFileError, “file ‘%s’ does not exist” % source
(Pdb)
ndtreviv Said,
March 27, 2008 @ 7:14 am
I’m a Java programmer and this actually makes writing an Objective-C plugin feasible!
The only thing is…can you add some instructions for doing it in XCode? That would make this the ultimate Apple Plugin tutorial for me
fdiv.net » Apple Mail Hack: Move Message To Sent Folder Said,
April 20, 2008 @ 9:46 am
[…] armed with James Eagan’s article on writing mailbundles I wrote a hack which adds a menu item, complete with keyboard shortcut, allowing the user to easily […]
John Said,
June 3, 2008 @ 5:01 pm
James,
People around here print their emails a lot, and we share very busy printers. We are finally switching everyone to Mail–from Eudora. Eudora put a single-line header with recipient(s) name, received timestamp, subject and page number at the top of each page. Users are getting cranky about having to dig through the printer output to find the emails they have printed.
Before I dive in to writing a Mail plugin to do this, do you see any gotchas? In other words, please stop me before I spend a lot of time on something that is impossible. And I know I should just tell them to stop printing things, but I kinda want to try to write a plugin.
Thanks,
John
Andy Bell Said,
July 16, 2008 @ 3:44 am
Great article, just what I was looking for.
I do have a question, do you know where in the long class dump I will be able to catch when a new mail is delivered and then find out which folder it was sent to?
Thanks
Andy