''' OutlookSmartQuote Python COM add-in edition By Ken Deeter OutlookSmartQuote version 0.9 copyright 2007 Ken Deeter. All rights reserved. THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. In short, this is something I just hacked up to make outlook tolerable for me. Don't expect any support. I haven't decided on a license for this yet. For now please don't redistribute, and send me patches to smartquote@kendeeter.com. I realize that this is kinda dumb given that I'm shipping this in source code form, so don't bother pointing it out. Some of this code is based on the outlook com addin example that is distributed along with the ActivePython distribution (specifically the win32com component.) I couldn't figure out what the licensing for that example code was. If someone knows, let me know. ''' import sys import os import re # To register the addin, simply execute: # smartquoteaddin.py # This will install the COM server, and write the necessary # AddIn key to Outlook # # To unregister completely: # smartquoteaddin.py --unregister # # To debug, execute: # smartquoteaddin.py --debug # # Then open Pythonwin, and select "Tools->Trace Collector Debugging Tool" # Restart Outlook, and you should see some output generated. # # NOTE: If the AddIn fails with an error, Outlook will re-register # the addin to not automatically load next time Outlook starts. To # correct this, simply re-register the addin (see above) # Per comments in SpamBayes Outlook addin.py, # I've added this here and in two other spots. import locale locale.setlocale(locale.LC_NUMERIC, "C") import win32api import win32con from win32com import universal from win32com.server.exception import COMException from win32com.client import gencache, DispatchWithEvents import winerror import pythoncom from win32com.client import constants # Support for COM objects we use. gencache.EnsureModule('{00062FFF-0000-0000-C000-000000000046}', 0, 9, 0, bForDemand=True) # Outlook 9 gencache.EnsureModule('{2DF8D04C-5BFA-101B-BDE5-00AA0044DE52}', 0, 2, 1, bForDemand=True) # Office 9 # The TLB defining the interfaces we implement universal.RegisterInterfaces('{AC0714F2-3D04-11D1-AE7D-00A0C90F26F4}', 0, 1, 0, ["_IDTExtensibility2"]) # This is code from the example I copied #class ButtonEvent: # def Init(self, application): # self.application = application # self.activeExplorer = None # # def Close(self): # self.application = None # self.activeExplorer = None # self.close() # # def OnClick(self, button, cancel): # locale.setlocale(locale.LC_NUMERIC, "C") # if self.activeExplorer is None: # self.activeExplorer = self.application.ActiveExplorer() # # # Get the ID of the folder we're currently in. # currentFolder = self.activeExplorer.CurrentFolder # currentFolderID = currentFolder.Parent.StoreID, currentFolder.EntryID # # # Get the ID of the 'Sent Items' folder. # sentFolder = self.application.Session.GetDefaultFolder(constants.olFolderSentMail) # sentFolderID = sentFolder.Parent.StoreID, sentFolder.EntryID # # # If we're in the 'Sent Items' folder... # if currentFolderID[1] == sentFolderID[1]: # self.getSentEmailInfo() # else: # not in the 'Sent Items' folder. # self.folderError() # # def getSentEmailInfo(self): # # Grab all of the fields from the currently # # selected sent mail item. # sentMailItem = self.activeExplorer.Selection.Item(1) # try: # To = sentMailItem.To # CC = sentMailItem.CC # BCC = sentMailItem.BCC # Subject = sentMailItem.Subject # Body = sentMailItem.Body # except: # Don't forget to change this. # print "Hit an error in getSentEmailInfo()" # # Better kill the ActiveExplorer or Outlook # # will hang on shutdown. # self.activeExplorer = None # self.createMailMessage(To, CC, BCC, Subject, Body) # # def createMailMessage(self, To, CC, BCC, Subject, Body): # # Create the new mail message.... # # # # There's usually a 'download' link in the body of the # # 'Invitation to Bid', if we find it, we'll use it. # site = re.findall("http://.*", Body) # if site: # # Our link is top-level, let's create a new link # # that points right to the addendum directory. # link = site[0].strip("\r") + "/addendum" # else: # link = "" # mailMessage = self.application.CreateItem(constants.olMailItem) # mailMessage.To = To # mailMessage.CC = CC # mailMessage.BCC = BCC # if "RE:" in Subject: # mailMessage.Subject = Subject # else: # mailMessage.Subject = "RE: " + Subject # mailMessage.Body = "A new addendum has been issued for this project.\n\n%s" % (link) # mailMessage.Display() # # Kill the ActiveExplorer. # self.activeExplorer = None # # def folderError(self): # errMsg = "You must be in the 'Sent Items' Folder to use this tool!" # win32api.MessageBox(0, # errMsg, # 'Folder Error', # win32con.MB_ICONEXCLAMATION) # # Kill the ActiveExplorer. # self.activeExplorer = None class BaseEvent: '''Base class for responding to toolbar buttons Most of the code is the same, except for the kind of action we want to prepare (reply, replyall, or forward). The common code is in this class, and each subclass overrides the Response() method. ''' def Init(self, application): self.application = application def Close(self): self.application = None self.close() def Response(self, mailItem): win32api.MessageBox(0, 'BaseEvent\'s Response() called. Something wrong.' 'Code Error', win32con.MB_ICONEXCLAMATION) def _GetCurrentItem(self): activeWindow = self.application.ActiveWindow() # XXX - we need to figure out if activeWindow is an Explorer or an # Inspector. Explorer's have Selection properties, so the test is to # see if activeWindow has Selection. Of course, it's better to just # look at the class name, but I don't know how to do that yet. Also it # doesn't really matter yet because I haven't figured out how to add # buttons to inspectors anyways. if hasattr(activeWindow, 'Selection'): # This is an explorer window, so get the selection print activeWindow.Selection return activeWindow.Selection.Item(1) else: # This is an inspector window so get the CurrentItem print activeWindow.CurrentItem def OnClick(self, button, cancel): locale.setlocale(locale.LC_NUMERIC, "C") origMailItem = self._GetCurrentItem() print 'got', origMailItem if not origMailItem.BodyFormat == constants.olFormatPlain: print 'not a plain text message, doing normal reply' replyItem = self.Response(origMailItem) replyItem.Display() else: print 'this is a plain text message' text = origMailItem.Body text = FixBody(text) # Note: Even if the message is a direct message, SentOnBehalfOfName still # works. It should always be used. text = "\n\n%s wrote:\n" % (origMailItem.SentOnBehalfOfName) + text replyItem = self.Response(origMailItem) replyItem.Body = text replyItem.Display() class ReplyEvent(BaseEvent): def Response(self, mailItem): return mailItem.Reply() class ReplyAllEvent(BaseEvent): def Response(self, mailItem): return mailItem.ReplyAll() class ForwardEvent(BaseEvent): def Response(self, mailItem): return mailItem.Forward() class OutlookAddin: # This is the info that the pycom stuff uses when registering this class _com_interfaces_ = ['_IDTExtensibility2'] _public_methods_ = [] _reg_clsctx_ = pythoncom.CLSCTX_INPROC_SERVER _reg_clsid_ = "{02646dfe-12a3-4c8f-a8d8-d82134b22a91}" _reg_progid_ = "SmartQuote.OutlookAddin" _reg_policy_spec_ = "win32com.server.policy.EventHandlerPolicy" def __init__(self): self.application = None self.buttons = [] def OnConnection(self, application, connectMode, addin, custom): locale.setlocale(locale.LC_NUMERIC, "C") self.application = application print 'someone connected' # ActiveExplorer may be none when started without a UI (eg, WinCE synchronisation) activeExplorer = application.ActiveExplorer() if activeExplorer is not None: bars = activeExplorer.CommandBars toolbar = bars.Item("Standard") buttonInfo = [ ("Ken's Reply", "Creates a nicely quoted reply.", "smartquote-reply", ReplyEvent), ("Ken's Reply All", "Creates a nicely quoted reply all.", "smartquote-replyall", ReplyAllEvent), ("Ken's Forward", "Creates a nicely quoted forward", "smartquote-forward", ForwardEvent), ] for (bcaption, btooltip, bparam, bklass) in buttonInfo: # Look for an item we added previously. We do this by iterating # through all button controls, and finding ones with the correct # Parameter property which we set when we created the buttons. button = None buttons = bars.FindControls(Type=constants.msoControlButton) for b in buttons: if b.Parameter == bparam: button = b if not button: print "Couldn't find the button %s created last time, making a new one" % bparam # Create the toolbar button button = toolbar.Controls.Add(Type=constants.msoControlButton, Parameter=bparam, Temporary=False) button.Caption = bcaption button.TooltipText = btooltip button.Enabled = True else: print "Found our button %s!" % bparam # Hook up the button we found or created to our *Event classes button = DispatchWithEvents(button, bklass) button.Init(self.application) # Remember the button so that we can clean up later. self.buttons.append(button) def OnDisconnection(self, mode, custom): # clean up our buttons for b in self.buttons: b.Close() self.application = None print "Gateway Count: ", pythoncom._GetGatewayCount() print "Interface Count: ", pythoncom._GetInterfaceCount() def OnAddInsUpdate(self, custom): pass def OnStartupComplete(self, custom): pass def OnBeginShutdown(self, custom): pass def RegisterAddin(klass): import _winreg key = _winreg.CreateKey(_winreg.HKEY_CURRENT_USER, "Software\\Microsoft\\Office\\Outlook\\Addins") subkey = _winreg.CreateKey(key, klass._reg_progid_) _winreg.SetValueEx(subkey, "CommandLineSafe", 0, _winreg.REG_DWORD, 0) _winreg.SetValueEx(subkey, "LoadBehavior", 0, _winreg.REG_DWORD, 3) _winreg.SetValueEx(subkey, "Description", 0, _winreg.REG_SZ, "Invitation to Bid Tool") _winreg.SetValueEx(subkey, "FriendlyName", 0, _winreg.REG_SZ, "Invitation Helper") def UnregisterAddin(klass): import _winreg try: _winreg.DeleteKey(_winreg.HKEY_CURRENT_USER, "Software\\Microsoft\\Office\\Outlook\\Addins\\" + klass._reg_progid_) except WindowsError: pass import sys targetWidth = 76 def nicifyString(input, width): '''Returns a string broken into multiple lines input: string to break, with no newlines. width: integer, maximum column width for each line returns: array of strings, one for each line (no \n's appended. ''' retlines = [] curLineStartPos = 0 curPos = 0 breakCandidate = 0 secondaryBreakCandidate = 0 done = False while 1: if curPos >= len(input): # handle the last line lastline = input[curLineStartPos:] retlines.append(lastline) break if curPos - curLineStartPos < width: # still have space if input[curPos].isspace(): breakCandidate = curPos elif input[curPos] in ['-', '/']: secondaryBreakCandidate = curPos + 1 else: # find last break candidate and create line if curLineStartPos == breakCandidate: # some single word is longer than an entire line. # look for secondaries if secondaryBreakCandidate == curLineStartPos: # ugg, this line is unbreakable. Just arbitrarily break somewhere retlines.append(input[curLineStartPos:curLineStartPos + width]) curLineStartPos = curLineStartPos + width else: retlines.append(input[curLineStartPos:secondaryBreakCandidate]) curLineStartPos = secondaryBreakCandidate else: retlines.append(input[curLineStartPos:breakCandidate]) # the + 1 is for the space curLineStartPos = breakCandidate + 1 breakCandidate = curLineStartPos secondaryBreakCandidate = curLineStartPos curPos += 1 print retlines return retlines def countQuoteLevel(line): '''Counts the quote level of a line line: a string representing a line returns: the "quote level" of a line ''' count = 0 for i in xrange(len(line)): if line[i].isspace(): continue elif line[i] == '>': count += 1 else: return count else: return count quoteRE = re.compile('^(> ? ?)*> ?') def stripQuoteIndents(line): return quoteRE.sub('', line) def quotePrefixString(level): return '> ' * level class Block: # this is set of adjacent lines with the same indent def __init__(self): self.indentCount = 0 self.lines =[] class Paragraph: WHITESPACE = 2000 TEXT = 3000 def __init__(self, type = None): if not type: type = self.TEXT self.type = type self.lines = [] def Fix(self): # reflow lines in this block done = False while not done: done = True paragraphs = [] curParagraph = self.Paragraph() # create a list of paragraphs for i in self.lines: i = stripQuoteIndents(i) curMode = self.Paragraph.TEXT if not i.strip(): curMode = self.Paragraph.WHITESPACE if curParagraph.type != curMode: # commit paragraph and make a new one paragraphs.append(curParagraph) curParagraph = self.Paragraph(curMode) curParagraph.lines.append(i) # append the last one if curParagraph.lines: paragraphs.append(curParagraph) # reflow each of them and recretate the block newlines = [] for p in paragraphs: if p.type == p.WHITESPACE: newlines.append(quotePrefixString(self.indentCount)) else: target = targetWidth - 2 * self.indentCount newplines = [] numlines = len(p.lines) curline = 0 while curline < numlines : if len(p.lines[curline]) > target: # we found a long line, try to reflow with subsequent # lines, adding new lines until the reflowed version # has no more lines than the original, or there are no # more lines left in this paragraph endindex = 0 for n in xrange(curline, numlines): print curline, n orig = p.lines[curline:n] reflowed = nicifyString(' '.join([ i.strip() for i in orig]), target) if len(orig) == len(reflowed): endindex = n + 1 break else: restlines = p.lines[curline:] reflowed = nicifyString(' '.join([ i.strip() for i in p.lines[curline:]]), target) endindex = numlines newplines += reflowed curline = endindex continue else: newplines.append(p.lines[curline]) curline += 1 p.lines = newplines for i in p.lines: newlines.append(quotePrefixString(self.indentCount) + i) self.lines = newlines def Blockify(lines): blocks = [] def makeBlock(): # quote level changed. store old block and start on a new one b = Block() b.indentCount = curQuoteLevel b.lines = curBlockLines blocks.append(b) curQuoteLevel = 0 curBlockLines = [] for line in lines: ql = countQuoteLevel(line) if ql == curQuoteLevel: curBlockLines.append(line) else: makeBlock() curQuoteLevel = ql curBlockLines = [line] else: # make a block for the remaining set makeBlock() return blocks def FixBody(bodytext): '''Quote the text of a message bodyText: a string that represents the body of a message. Assumed to be one string with newlines (as opposed to an array) returns: a string that contains the quoted version of the input. Also string with newlines. ''' lines = bodytext.split(u'\n') newlines = [ u'> ' + i for i in lines ] blocks = Blockify(newlines) outlines = [] for ablock in blocks: print 'processing block' #for i in ablock.lines: #print i.rstrip() ablock.Fix() print 'processed block' #for i in ablock.lines: # print i.rstrip() outlines += ablock.lines return '\n'.join(outlines) if __name__ == '__main__': import win32com.server.register win32com.server.register.UseCommandLine(OutlookAddin) if "--unregister" in sys.argv: UnregisterAddin(OutlookAddin) else: RegisterAddin(OutlookAddin)