# HG changeset patch # User Paul Boddie # Date 1411335444 -7200 # Node ID 4bc0a825daad93f29439063ac187497fec9ccf06 An iMIP agent for Postfix. diff -r 000000000000 -r 4bc0a825daad imip_agent.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imip_agent.py Sun Sep 21 23:37:24 2014 +0200 @@ -0,0 +1,102 @@ +#!/usr/bin/env python + +from email import message_from_file +from vCalendar import parse +import sys + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +# Postfix exit codes. + +EX_USAGE = 64 +EX_DATAERR = 65 +EX_NOINPUT = 66 +EX_NOUSER = 67 +EX_NOHOST = 68 +EX_UNAVAILABLE = 69 +EX_SOFTWARE = 70 +EX_OSERR = 71 +EX_OSFILE = 72 +EX_CANTCREAT = 73 +EX_IOERR = 74 +EX_TEMPFAIL = 75 +EX_PROTOCOL = 76 +EX_NOPERM = 77 +EX_CONFIG = 78 + +# Permitted iTIP content types. + +itip_content_types = [ + "text/calendar", # from RFC 6047 + "text/x-vcalendar", "application/ics", # other possibilities + ] + +def process(f): + msg = message_from_file(f) + + # Handle messages with iTIP parts. + + for part in msg.walk(): + if part.get_content_type() in itip_content_types and \ + part.get_param("method"): + + handle_itip_part(part) + +def get_itip_elements(elements): + d = {} + for name, attr, value in elements: + if isinstance(value, list): + d[name] = attr, get_itip_elements(value) + else: + d[name] = attr, value + return d + +def get_value(d, name): + if d.has_key(name): + attr, value = d[name] + return value + else: + return None + +def handle_itip_part(part): + method = part.get_param("method") + + f = StringIO(part.get_payload(decode=True)) + doctype, attrs, elements = parse(f, encoding=part.get_content_charset()) + + if doctype == "VCALENDAR": + itip = get_itip_elements(elements) + + if get_value(itip, "METHOD") == method: + for name, handler in [ + ("VFREEBUSY", handle_itip_freebusy), + ("VEVENT", handle_itip_event), + ("VTODO", handle_itip_todo), + ("VJOURNAL", handle_itip_journal), + ]: + + obj = get_value(itip, name) + if obj: + uid = get_value(obj, "UID") + if uid: + handler(obj, method) + +def handle_itip_freebusy(freebusy, method): + print >>open("/tmp/imip.txt", "a"), freebusy + +def handle_itip_event(event, method): + print >>open("/tmp/imip.txt", "a"), event + +def handle_itip_todo(todo, method): + print >>open("/tmp/imip.txt", "a"), todo + +def handle_itip_journal(journal, method): + print >>open("/tmp/imip.txt", "a"), journal + +if __name__ == "__main__": + process(sys.stdin) + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r 000000000000 -r 4bc0a825daad vCalendar.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/vCalendar.py Sun Sep 21 23:37:24 2014 +0200 @@ -0,0 +1,259 @@ +#!/usr/bin/env python + +""" +Parsing of vCalendar and iCalendar files. + +Copyright (C) 2008, 2009, 2011, 2013 Paul Boddie + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . + +-------- + +References: + +RFC 5545: Internet Calendaring and Scheduling Core Object Specification + (iCalendar) + http://tools.ietf.org/html/rfc5545 + +RFC 2445: Internet Calendaring and Scheduling Core Object Specification + (iCalendar) + http://tools.ietf.org/html/rfc2445 +""" + +import vContent +import re + +try: + set +except NameError: + from sets import Set as set + +# Format details. + +QUOTED_PARAMETERS = set([ + "ALTREP", "DELEGATED-FROM", "DELEGATED-TO", "DIR", "MEMBER", "SENT-BY" + ]) +MULTIVALUED_PARAMETERS = set([ + "DELEGATED-FROM", "DELEGATED-TO", "MEMBER" + ]) +QUOTED_TYPES = set(["URI"]) + +unquoted_separator_regexp = re.compile(r"(? + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . + +-------- + +References: + +RFC 5545: Internet Calendaring and Scheduling Core Object Specification + (iCalendar) + http://tools.ietf.org/html/rfc5545 + +RFC 2445: Internet Calendaring and Scheduling Core Object Specification + (iCalendar) + http://tools.ietf.org/html/rfc2445 + +RFC 2425: A MIME Content-Type for Directory Information + http://tools.ietf.org/html/rfc2425 + +RFC 2426: vCard MIME Directory Profile + http://tools.ietf.org/html/rfc2426 +""" + +try: + set +except NameError: + from sets import Set as set + +# Encoding-related imports. + +import base64, quopri +import codecs + +# Tokenisation help. + +import re + +# Configuration. + +default_encoding = "utf-8" + +# Reader and parser classes. + +class Reader: + + "A simple class wrapping a file, providing simple pushback capabilities." + + def __init__(self, f, non_standard_newline=0): + + """ + Initialise the object with the file 'f'. If 'non_standard_newline' is + set to a true value (unlike the default), lines ending with CR will be + treated as complete lines. + """ + + self.f = f + self.non_standard_newline = non_standard_newline + self.lines = [] + self.line_number = 1 # about to read line 1 + + def close(self): + + "Close the reader." + + self.f.close() + + def pushback(self, line): + + """ + Push the given 'line' back so that the next line read is actually the + given 'line' and not the next line from the underlying file. + """ + + self.lines.append(line) + self.line_number -= 1 + + def readline(self): + + """ + If no pushed-back lines exist, read a line directly from the file. + Otherwise, read from the list of pushed-back lines. + """ + + self.line_number += 1 + if self.lines: + return self.lines.pop() + else: + # Sanity check for broken lines (\r instead of \r\n or \n). + line = self.f.readline() + while line.endswith("\r") and not self.non_standard_newline: + s = self.f.readline() + if not s: + break + line += s + if line.endswith("\r") and self.non_standard_newline: + return line + "\n" + else: + return line + + def read_content_line(self): + + """ + Read an entire content line, itself potentially consisting of many + physical lines of text, returning a string. + """ + + # Skip blank lines. + + line = self.readline() + while line: + line_stripped = line.rstrip("\r\n") + if not line_stripped: + line = self.readline() + else: + break + else: + return "" + + # Strip all appropriate whitespace from the right end of each line. + # For subsequent lines, remove the first whitespace character. + # See section 4.1 of the iCalendar specification. + + lines = [line_stripped] + + line = self.readline() + while line.startswith(" ") or line.startswith("\t"): + lines.append(line[1:].rstrip("\r\n")) + line = self.readline() + + # Since one line too many will have been read, push the line back into + # the file. + + if line: + self.pushback(line) + + return "".join(lines) + + def get_content_line(self): + + "Return a content line object for the current line." + + return ContentLine(self.read_content_line()) + +class ContentLine: + + "A content line which can be searched." + + SEPARATORS = re.compile('[;:"]') + SEPARATORS_PLUS_EQUALS = re.compile('[=;:"]') + + def __init__(self, text): + self.text = text + self.start = 0 + + def __repr__(self): + return "ContentLine(%r)" % self.text + + def get_remaining(self): + + "Get the remaining text from the content line." + + return self.text[self.start:] + + def search(self, targets): + + """ + Find one of the 'targets' in the text, returning the string from the + current position up to the target found, along with the target string, + using a tuple of the form (string, target). If no target was found, + return the entire string together with a target of None. + + The 'targets' parameter must be a regular expression object or an object + compatible with the API of such objects. + """ + + text = self.text + start = pos = self.start + length = len(text) + + # Remember the first target. + + first = None + first_pos = None + in_quoted_region = 0 + + # Process the text, looking for the targets. + + while pos < length: + match = targets.search(text, pos) + + # Where nothing matches, end the search. + + if match is None: + pos = length + + # Where a double quote matches, toggle the region state. + + elif match.group() == '"': + in_quoted_region = not in_quoted_region + pos = match.end() + + # Where something else matches outside a region, stop searching. + + elif not in_quoted_region: + first = match.group() + first_pos = match.start() + break + + # Otherwise, keep looking for the end of the region. + + else: + pos = match.end() + + # Where no more input can provide the targets, return a special result. + + else: + self.start = length + return text[start:], None + + self.start = match.end() + return text[start:first_pos], first + +class StreamParser: + + "A stream parser for content in vCard/vCalendar/iCalendar-like formats." + + def __init__(self, f): + + "Initialise the parser for the given file 'f'." + + self.f = f + + def close(self): + + "Close the reader." + + self.f.close() + + def __iter__(self): + + "Return self as the iterator." + + return self + + def next(self): + + """ + Return the next content item in the file as a tuple of the form + (name, parameters, values). + """ + + return self.parse_content_line() + + def decode_content(self, value): + + "Decode the given 'value', replacing quoted characters." + + return value.replace("\r", "").replace("\\N", "\n").replace("\\n", "\n") + + # Internal methods. + + def parse_content_line(self): + + """ + Return the name, parameters and value information for the current + content line in the file being parsed. + """ + + f = self.f + line_number = f.line_number + line = f.get_content_line() + + # Read the property name. + + name, sep = line.search(line.SEPARATORS) + name = name.strip() + + if not name and sep is None: + raise StopIteration + + # Read the parameters. + + parameters = {} + + while sep == ";": + + # Find the actual modifier. + + parameter_name, sep = line.search(line.SEPARATORS_PLUS_EQUALS) + parameter_name = parameter_name.strip() + + if sep == "=": + parameter_value, sep = line.search(line.SEPARATORS) + parameter_value = parameter_value.strip() + else: + parameter_value = None + + # Append a key, value tuple to the parameters list. + + parameters[parameter_name] = parameter_value + + # Get the value content. + + if sep != ":": + raise ValueError, (line_number, line) + + # Obtain and decode the value. + + value = self.decode(name, parameters, line.get_remaining()) + + return name, parameters, value + + def decode(self, name, parameters, value): + + "Decode using 'name' and 'parameters' the given 'value'." + + encoding = parameters.get("ENCODING") + charset = parameters.get("CHARSET") + + value = self.decode_content(value) + + if encoding == "QUOTED-PRINTABLE": + return unicode(quopri.decodestring(value), charset or "iso-8859-1") + elif encoding == "BASE64": + return base64.decodestring(value) + else: + return value + +class ParserBase: + + "An abstract parser for content in vCard/vCalendar/iCalendar-like formats." + + def __init__(self): + + "Initialise the parser." + + self.names = [] + + def parse(self, f, parser_cls=None): + + "Parse the contents of the file 'f'." + + parser = (parser_cls or StreamParser)(f) + + for name, parameters, value in parser: + + if name == "BEGIN": + self.names.append(value) + self.startComponent(value, parameters) + + elif name == "END": + start_name = self.names.pop() + if start_name != value: + raise ParseError, "Mismatch in BEGIN and END declarations (%r and %r) at line %d." % ( + start_name, value, f.line_number) + + self.endComponent(value) + + else: + self.handleProperty(name, parameters, value) + +class Parser(ParserBase): + + "A SAX-like parser for vCard/vCalendar/iCalendar-like formats." + + def __init__(self): + ParserBase.__init__(self) + self.components = [] + + def startComponent(self, name, parameters): + + """ + Add the component with the given 'name' and 'parameters', recording an + empty list of children as part of the component's content. + """ + + component = self.handleProperty(name, parameters) + self.components.append(component) + return component + + def endComponent(self, name): + + """ + End the component with the given 'name' by removing it from the active + component stack. If only one component exists on the stack, retain it + for later inspection. + """ + + if len(self.components) > 1: + return self.components.pop() + + # Or return the only element. + + elif self.components: + return self.components[0] + + def handleProperty(self, name, parameters, value=None): + + """ + Record the property with the given 'name', 'parameters' and optional + 'value' as part of the current component's children. + """ + + component = self.makeComponent(name, parameters, value) + self.attachComponent(component) + return component + + # Component object construction/manipulation methods. + + def attachComponent(self, component): + + "Attach the given 'component' to its parent." + + if self.components: + component_name, component_parameters, component_children = self.components[-1] + component_children.append(component) + + def makeComponent(self, name, parameters, value=None): + + """ + Make a component object from the given 'name', 'parameters' and optional + 'value'. + """ + + return (name, parameters, value or []) + + # Public methods. + + def parse(self, f, parser_cls=None): + + "Parse the contents of the file 'f'." + + ParserBase.parse(self, f, parser_cls) + return self.components[0] + +# Writer classes. + +class Writer: + + "A simple class wrapping a file, providing simple output capabilities." + + default_line_length = 76 + + def __init__(self, write, line_length=None): + + """ + Initialise the object with the given 'write' operation. If 'line_length' + is set, the length of written lines will conform to the specified value + instead of the default value. + """ + + self._write = write + self.line_length = line_length or self.default_line_length + self.char_offset = 0 + + def write(self, text): + + "Write the 'text' to the file." + + write = self._write + line_length = self.line_length + + i = 0 + remaining = len(text) + + while remaining: + space = line_length - self.char_offset + if remaining > space: + write(text[i:i + space]) + write("\r\n ") + self.char_offset = 1 + i += space + remaining -= space + else: + write(text[i:]) + self.char_offset += remaining + i += remaining + remaining = 0 + + def end_line(self): + + "End the current content line." + + if self.char_offset > 0: + self.char_offset = 0 + self._write("\r\n") + +class StreamWriter: + + "A stream writer for content in vCard/vCalendar/iCalendar-like formats." + + def __init__(self, f): + + "Initialise the stream writer with the given 'f' stream object." + + self.f = f + + def write(self, name, parameters, value): + + """ + Write a content line, serialising the given 'name', 'parameters' and + 'value' information. + """ + + self.write_content_line(name, self.encode_parameters(parameters), self.encode_value(name, parameters, value)) + + # Internal methods. + + def write_content_line(self, name, encoded_parameters, encoded_value): + + """ + Write a content line for the given 'name', 'encoded_parameters' and + 'encoded_value' information. + """ + + f = self.f + + f.write(name) + for param_name, param_value in encoded_parameters.items(): + f.write(";") + f.write(param_name) + f.write("=") + f.write(param_value) + f.write(":") + f.write(encoded_value) + f.end_line() + + def encode_quoted_parameter_value(self, value): + + "Encode the given 'value'." + + return '"%s"' % value + + def encode_value(self, name, parameters, value): + + """ + Encode using 'name' and 'parameters' the given 'value' so that the + resulting encoded form employs any specified character encodings. + """ + + encoding = parameters.get("ENCODING") + charset = parameters.get("CHARSET") + + if encoding == "QUOTED-PRINTABLE": + value = quopri.encodestring(value.encode(charset or "iso-8859-1")) + elif encoding == "BASE64": + value = base64.encodestring(value) + + return self.encode_content(value) + + # Overrideable methods. + + def encode_parameters(self, parameters): + + """ + Encode the given 'parameters' according to the vCalendar specification. + """ + + encoded_parameters = {} + + for param_name, param_value in parameters.items(): + + # Basic format support merely involves quoting values which seem to + # need it. Other more specific formats may define exactly which + # parameters should be quoted. + + if ContentLine.SEPARATORS.search(param_value): + param_value = self.encode_quoted_parameter_value(param_value) + + encoded_parameters[param_name] = param_value + + return encoded_parameters + + def encode_content(self, value): + + "Encode the given 'value', quoting characters." + + return value.replace("\n", "\\n") + +# Utility functions. + +def is_input_stream(stream_or_string): + return hasattr(stream_or_string, "read") + +def get_input_stream(stream_or_string, encoding=None): + if is_input_stream(stream_or_string): + return stream_or_string + else: + return codecs.open(stream_or_string, encoding=(encoding or default_encoding)) + +def get_output_stream(stream_or_string, encoding=None): + if hasattr(stream_or_string, "write"): + return stream_or_string + else: + return codecs.open(stream_or_string, "w", encoding=(encoding or default_encoding)) + +# Public functions. + +def parse(stream_or_string, encoding=None, non_standard_newline=0, parser_cls=None): + + """ + Parse the resource data found through the use of the 'stream_or_string', + which is either a stream providing Unicode data (the codecs module can be + used to open files or to wrap streams in order to provide Unicode data) or a + filename identifying a file to be parsed. + + The optional 'encoding' can be used to specify the character encoding used + by the file to be parsed. + + The optional 'non_standard_newline' can be set to a true value (unlike the + default) in order to attempt to process files with CR as the end of line + character. + + As a result of parsing the resource, the root node of the imported resource + is returned. + """ + + stream = get_input_stream(stream_or_string, encoding) + reader = Reader(stream, non_standard_newline) + + # Parse using the reader. + + try: + parser = (parser_cls or Parser)() + return parser.parse(reader) + + # Close any opened streams. + + finally: + if not is_input_stream(stream_or_string): + reader.close() + +def iterparse(stream_or_string, encoding=None, non_standard_newline=0, parser_cls=None): + + """ + Parse the resource data found through the use of the 'stream_or_string', + which is either a stream providing Unicode data (the codecs module can be + used to open files or to wrap streams in order to provide Unicode data) or a + filename identifying a file to be parsed. + + The optional 'encoding' can be used to specify the character encoding used + by the file to be parsed. + + The optional 'non_standard_newline' can be set to a true value (unlike the + default) in order to attempt to process files with CR as the end of line + character. + + An iterator is returned which provides event tuples describing parsing + events of the form (name, parameters, value). + """ + + stream = get_input_stream(stream_or_string, encoding) + reader = Reader(stream, non_standard_newline) + parser = (parser_cls or StreamParser)(reader) + return parser + +def iterwrite(stream_or_string=None, write=None, encoding=None, line_length=None, writer_cls=None): + + """ + Return a writer which will either send data to the resource found through + the use of 'stream_or_string' or using the given 'write' operation. + + The 'stream_or_string' parameter may be either a stream accepting Unicode + data (the codecs module can be used to open files or to wrap streams in + order to accept Unicode data) or a filename identifying a file to be + written. + + The optional 'encoding' can be used to specify the character encoding used + by the file to be written. + + The optional 'line_length' can be used to specify how long lines should be + in the resulting data. + """ + + if stream_or_string: + stream = get_output_stream(stream_or_string, encoding) + _writer = Writer(stream.write, line_length) + elif write: + _writer = Writer(write, line_length) + else: + raise IOError, "No stream, filename or write operation specified." + + return (writer_cls or StreamWriter)(_writer) + +# vim: tabstop=4 expandtab shiftwidth=4