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:

:::objective-c
+ (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:

:::python
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:

:::python
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:

:::python
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:

:::python
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.