Kiran Jonnalagadda (jace) wrote,
Kiran Jonnalagadda
jace

  • Music:

MMS to LJ gateway

I just wrote an MMS to LiveJournal gateway so I can post pictures directly from my phone. 284 lines, five hours from scratch. Not well tested, not secure.

The script requires a dedicated POP3 mailbox that the camera phone user sends MMS messages to, and that it will poll and re-post to LJ. Requires Python 2.2 or 2.3 and expects to be running on the same server where images will be hosted.

#!/usr/bin/env python2.3

"""
MMS2LJ script polls a POP3 mailbox for MMS messages and posts them to
LiveJournal. Looks for configuration in ~/.mms2ljrc. Creates a sample
configuration file if none is found. Since the config file contains
passwords, please ensure that it is not world-readable. Use this script
with a scheduler like cron.

Copyright (C) 2004, Kiran Jonnalagadda. All rights reserved.
Source code may be used under the terms of the BSD license.

Version 0.1
"""

config_template="""
# This configuration file contains settings for the MMS to LJ gateway.
# Since it contains passwords, please ensure that it is not
# world-readable. Your only security against abuse of the gateway is
# that the POP3 account is unknown (to abusers), and that the From
# address in each message is your mobile phone (in case a regular email
# spammer accidentally hits your account). There is no per-post security
# mechanism.

# The POP3 server that will be polled for new messages. This account
# should receive only posts for LJ.
pop3user = 'test'
pop3pass = 'testpass'
pop3host = 'your-mail-server-here'
mailfrom = 'your-mobile-number@your-service-provider'
rejects_to = 'your@email' # Rejected mail is redirected to this address.
# The rejects option is currently not implemented.
ignore_subject = False

# LiveJournal account settings.
ljserver = 'http://www.livejournal.com/'
ljuser = 'test'
ljpass = 'test'
ljjournal = 'test'
allow_comments = True
email_comments = True

# Image handling options.
scale_images = True
scale_image_constraints = (320, 320)
link_to_full_size_image = True
img_alt_text = 'MMS Image'
img_class = ''
img_style = ''
img_border = 0

# Server paths. Images will be placed under file_root/year/month/day.
file_root = '~/public_html/moblog/'
server_root = 'http://your-web-server/~your-user-name/moblog/'
"""

import imp, sys, os, os.path, time, poplib, email, cgi, xmlrpclib, md5
from binascii import hexlify
from PIL import Image

config_filename = os.path.expanduser('~/.mms2ljrc')

# Check if configuration file exists. If not, create it and exit.
try:
    config_file = file(config_filename, 'r')
except IOError:
    config_file = file(config_filename, 'w')
    config_file.write(config_template)
    config_file.close()
    print "A sample configuration file was written to ~/.mms2ljrc"
    print "Please edit your settings and rerun this script."
    sys.exit(1)

# Try to read configuration. Note: this is not secure as the
# configuration file is an unrestricted python script. We assume the
# user does not want to cause damage to himself/herself.
try:
    config = imp.load_module('mms2ljrc', config_file, '.mms2ljrc',
        ('rc', 'r', imp.PY_SOURCE))
finally:
    config_file.close()

# Determine current day, for where in path to place images.
post_year, post_month, post_day = time.localtime()[:3]
# Stuff these into configuration
config.post_year = '%04d' % post_year
config.post_month = '%02d' % post_month
config.post_day = '%02d' % post_day

# Counter for all images processed in this run of the script.
imagecounter = 0

def md5sum(text):
    m = md5.new()
    m.update(text)
    return hexlify(m.digest())

def stuff_defaults(config, **kw):
    """
    Add default values to configuration.
    """
    for key, value in kw.items():
        if not hasattr(config, key):
            setattr(config, key, value)

def post_to_lj(config, subject, lj_text):
    """
    Post the given text to LiveJournal.
    """
    if config.ignore_subject:
        subject = ''
    post_time = time.localtime()

    lj = xmlrpclib.Server(os.path.join(config.ljserver,
        'interface/xmlrpc')).LJ.XMLRPC
    lj.postevent({
        'username': config.ljuser,
        'hpassword': md5sum(config.ljpass), # FIXME: Not secure!!!
        'event': lj_text,
        'lineendings': 'pc',
        'subject': subject,
        'year': str(post_time[0]),
        'mon': str(post_time[1]),
        'day': str(post_time[2]),
        'hour': str(post_time[3]),
        'min': str(post_time[4]),
        'usejournal': config.ljjournal
        })

def process_image(config, subtype, raw_data):
    """
    Process an image and return an image tag.
    """
    global imagecounter
    filename = '%d%d' % (time.time(), imagecounter)
    smallfilename = filename + '-scaled'
    imagecounter += 1
    subtype = subtype.lower()
    if subtype in ['jpeg', 'jpg']:
        filename += '.jpg'
        smallfilename += '.jpg'
    else:
        filename += '.' + subtype
        smallfilename += '.' + subtype

    # Make folder hierarchy.
    filepath = os.path.join(os.path.expanduser(config.file_root),
        config.post_year, config.post_month, config.post_day)
    serverpath = os.path.join(config.server_root,
        config.post_year, config.post_month, config.post_day)
    try:
        os.makedirs(filepath)
        # The odd thing here is that makedirs raises os.error both when
        # the folder already exists and when it can't be created.
    except os.error:
        pass

    # Write raw data to file.
    file(os.path.join(filepath, filename), 'w').write(raw_data)

    # And read it back again via PIL to scale image and get size.
    image = Image.open(os.path.join(filepath, filename))
    # Don't scale if not big enough.
    noscale = False
    if (image.size[0] < config.scale_image_constraints[0] and
        image.size[1] < config.scale_image_constraints[1]):
        noscale = True
    if config.scale_images and not noscale:
        image.thumbnail(config.scale_image_constraints,
            getattr(Image, 'ANTIALIAS', Image.NEAREST))
        image.save(os.path.join(filepath, smallfilename), quality = 90)
        imgsrc = os.path.join(serverpath, smallfilename)
        linkhref = os.path.join(serverpath, filename)
    else:
        imgsrc = os.path.join(serverpath, filename)
    imgwidth = image.size[0]
    imgheight = image.size[1]
    imgtag = '<img src="%s" width="%d" height="%d" alt="%s"' \
        ' border="%d"' % (imgsrc, imgwidth, imgheight,
        config.img_alt_text, config.img_border)
    if config.img_class:
        imgtag += ' class="%s"' % config.img_class
    if config.img_style:
        imgtag += ' style="%s"' % config.img_style
    imgtag += '>'

    if config.scale_images and config.link_to_full_size_image and not \
        noscale:
        imgtag = '<a href="%s">%s</a>' % (linkhref, imgtag)

    return imgtag

def process_message(config, msg_body):
    """
    Processes a given message body: parses body and posts to LJ.
    """
    lj_parts = []
    message = email.message_from_string(msg_body)
    if message.get('From', '') == config.mailfrom:
        if message.is_multipart():
            parts = message.get_payload()
        else:
            parts = [message]
        for part in parts:
            if not part.is_multipart(): # Lazy programmer syndrome.
                # Decode from transfer encoding to raw data.
                pvalue = part.get_payload()
                ce = part.get('Content-Transfer-Encoding', '')
                if ce == 'base64':
                    pvalue = email.base64MIME.decodestring(pvalue)
                elif ce == 'quoted-printable':
                    pvalue = email.quopriMIME.decodestring(pvalue)

                # Check content type.
                ct = part.get_content_type()
                if ct.startswith('text/'): # Treat all text as text.
                    # Check charset. Convert to UTF-8 if not already.
                    charset = part.get_content_charset()
                    if charset != 'utf-8':
                        # Convert to UTF-8.
                        pvalue = unicode(pvalue,
                            charset).encode('utf-8')
                    # HTML-quote text if type is text/plain.
                    if ct == 'text/plain':
                        pvalue = cgi.escape(pvalue)
                    lj_parts.append(pvalue)

                elif ct.startswith('image/'):
                    lj_parts.append(process_image(config,
                        part.get_subtype(), pvalue))
                # All other MIME types are ignored.
        # All parts processed. Post to LJ now.
        post_to_lj(config, message.get('Subject', ''),
            '\r\n'.join(lj_parts))
    else:
        # TODO: Reject code comes here.
        pass

def process_pop3(config):
    """
    Process messages on the POP3 server.
    """
    # Access POP server.
    pop3 = poplib.POP3(config.pop3host)
    pop3.user(config.pop3user)
    pop3.pass_(config.pop3pass)
    message_count = len(pop3.list()[1])
    for i in range(message_count):
        msg_body = '\r\n'.join(pop3.retr(i+1)[1])
        # Parse message body into components and make LJ post.
        process_message(config, msg_body)
        pop3.dele(i+1)
    pop3.quit()


# Main loop.
stuff_defaults(config,
    pop3user = '',
    pop3pass = '',
    pop3host = '',
    mailfrom = '',
    rejects_to = '',
    ignore_subject = False,
    ljserver = 'http://www.livejournal.com/',
    ljuser = '',
    ljpass = '',
    allow_comments = True,
    email_comments = True,
    scale_images = True,
    scale_image_constraints = (320, 320),
    link_to_full_size_image = True,
    img_alt_text = 'MMS Image',
    img_class = '',
    img_style = '',
    img_border = 0,
    file_root = '~/public_html/moblog/',
    server_root = ''
    )
# Set journal to default.
stuff_defaults(config, ljjournal = config.ljuser)

process_pop3(config)
# Temporary for testing:
#process_message(config, msg_body = file('test.eml', 'r').read())

Update: Newer versions of the code are now at my site.
Subscribe
  • Post a new comment

    Error

    Comments allowed for friends only

    Anonymous comments are disabled in this journal

    default userpic

    Your reply will be screened

  • 24 comments