Doing anything with SOAP is a pain without a WSDL, which is the case with Zimbra. All of the Howtos I found about SOAP and ruby either required a WSDL or making several classes in a special, undocumented way to trick a SOAP::RPC::Driver instance into working. Both were unacceptable. After much hardship, I found an easier to read way to do SOAP without an WSDL in ruby, by building SOAP::Elements myself. Here is the code, documented to be easy to read, use, and extend.

# Incomplete library for interacting with Zimbra
#
#  require 'zimbra'
#
#  host = 'zimbra.tylerlesmann.com'
#  user = 'root'
#  passwd = 'hard_password'
#  creds = Zimbra.authenticate(host, user, passwd)
#  usercreds = Zimbra.masquerade(host, creds.authToken, 'tlesmann')
#  Zimbra.createappointment(host, usercreds.authToken,
#    Time.local(2009, 6, 26), 'Make a blog post', 'Maybe some Java', [
#    '/home/tlesmann/Documents/java.png',
#    '/home/tlesmann/Documents/tutorial.pdf',
#  ])

require 'net/http'
require 'net/https'
require 'soap/element'
require 'soap/rpc/driver'
require 'soap/processor'
require 'soap/streamHandler'
require 'soap/property'
require 'zimbra/multipart'

module Zimbra
  # Builds and sends AuthRequest to a provided Zimbra host.
  #
  # Returns a SOAP::Mapping instance, with an authToken attribute
  def self.authenticate(host, name, password)
    header = SOAP::SOAPHeader.new
    body = SOAP::SOAPBody.new(element('AuthRequest', nil,
      {
        'xmlns' => 'urn:zimbraAdmin',
      },
      [
        element('name', name),
        element('password', password),
      ]
    ))
    envelope = SOAP::SOAPEnvelope.new(header, body)
    return send_soap(envelope, host)
  end

  # Builds and sends CreateAppointmentRequest to a provided Zimbra host.  The
  # attachments argument expects a list of filename strings.
  #
  # Returns a SOAP::Mapping instance
  def self.createappointment(host, authToken, start, subject, description='',
    attachments=[])
    header = SOAP::SOAPHeader.new
    context = element('context', nil, {'xmlns' => 'urn:zimbra'}, [
      element('authToken', authToken)
    ])
    header.add('context', context)
    aids = []
    for attachment in attachments
      aids << upload_attachment(host, authToken, attachment)
    end
    if aids.empty?
      attach = nil
    else
      attach = element('attach', nil, {
      'aid' => aids.join(",")
      })
    end
    body = SOAP::SOAPBody.new(element('CreateAppointmentRequest', nil,
      {
        'xmlns' => 'urn:zimbraMail'
      },
      [
        element('m', nil, {}, [
          element('inv', nil, {}, [
            element('comp', nil,
              {
                'status' => 'CONF',
                'allDay' => 1,
                'fb' => 'F',
                'name' => subject,
                'noBlob' => 1,
              },
              [
                datetime('s', start),
                datetime('e', start),
                element('descHtml', description),
                element('alarm', nil,
                  {
                    'action' => 'DISPLAY'
                  },
                  [
                    element('trigger', nil, {}, [
                      element('rel', nil, {
                        'm' => 1
                      })
                  ]),
                  element('desc', subject),
                  ]
                ),
              ]
            ),
          ]),
          attach
        ]),
      ]
    ))
    envelope = SOAP::SOAPEnvelope.new(header, body)
    send_soap(envelope, host)
  end

  # builds SOAP::SOAPElement with tag name with a *d* attribute of the
  # provided ruby Time
  def self.datetime(name, time)
    return element(name, nil, {'d' => time.strftime("%Y%m%d")})
  end

  # builds SOAP::SOAPElements the way SOAP::SOAPElement constructor _should_
  #
  #  element('AuthRequest', nil,
  #    {
  #      'xmlns' => 'urn:zimbraAdmin',
  #    },
  #    [
  #    element('name', 'whoa'),
  #    element('password', 'man'),
  #    ]
  #  )
  #
  # The returned SOAP::SOAPElement converted to XML would be:
  #
  #  <AuthRequest xmlns="urn:zimbraAdmin">
  #    <name>whoa</name>
  #    <password>man</password>
  #  </AuthRequest>
  def self.element(name, value=nil, attrs={}, children=[])
    element = SOAP::SOAPElement.new(name, value)
    element.extraattr.update(attrs)
    for child in children
      if child
        element.add(child)
      end
    end
    return element
  end

  # Builds and sends DelegateAuth Request to a provided Zimbra host.  The
  # authToken must be that of an admin!  The account arg is nothing fancy, just
  # the username of the user to spoof.
  #
  # Returns a SOAP::Mapping instance, with an authToken attribute
  def self.masquerade(host, authToken, account)
    header = SOAP::SOAPHeader.new
    context = element('context', nil, {'xmlns' => 'urn:zimbra'}, [
      element('authToken', authToken)
    ])
    header.add('context', context)
    body = SOAP::SOAPBody.new(element('DelegateAuthRequest', nil,
      {
          'xmlns' => 'urn:zimbraAdmin'
      },
      [
        element('account', account, {
          'by' => 'name',
        })
      ]
    ))
    envelope = SOAP::SOAPEnvelope.new(header, body)
    return send_soap(envelope, host)
  end

  # Marshals SOAP::Envelopes and sends them to a given Zimbra host
  #
  # Returns response as a SOAP::Mapping instance
  def self.send_soap(envelope, host)
    url = 'https://' + host + ':7071/service/admin/soap/'
    stream = SOAP::HTTPStreamHandler.new(SOAP::Property.new)
    request_string = SOAP::Processor.marshal(envelope)
    puts request_string if $DEBUG
    request = SOAP::StreamHandler::ConnectionData.new(request_string)
    response_string = stream.send(url, request).receive_string
    puts response_string if $DEBUG
    env = SOAP::Processor.unmarshal(response_string)
    return SOAP::Mapping.soap2obj(env.body.root_node)
  end

  # Uploads file to given Zimbra host
  #
  # Returns a string containing the Zimbra attachment id.  These attachments are
  # only accessible to the user that uploaded them.
  def self.upload_attachment(host, authToken, filename)
    params = Hash.new
    file = File.open(filename, "rb")
    params["attachment"] = file
    mp = Multipart::MultipartPost.new
    query, headers = mp.prepare_query(params)
    file.close
    headers['Cookie'] = 'ZM_AUTH_TOKEN=' + authToken
    url = URI.parse('https://' + host + '/service/upload')
    client = Net::HTTP.new(url.host, url.port)
    client.use_ssl = true
    response = client.post(url.path + '?fmt=raw', query, headers)
    return response.body.split(',')[2].strip.slice(1..-2)
  end
end

Note: I would have done this in python, if it were not needed for an existing rails application. ;)

Posted by Tyler Lesmann on June 24, 2009 at 16:14
Tagged as: mechanize ruby soap zimbra
Comments
#1 Bret Weinraub wrote this 2 years, 7 months ago

Would it have been easier in python?

#2 Tyler Lesmann wrote this 2 years, 7 months ago

For me it would have easier because I have more experience with python.

SOAP4R and SOAPpy are similar in functionality and ease of use. Both make SOAP more complex than it needs to be.

#3 Claudio wrote this 2 years, 3 months ago

There is a PHP version of this class?

#4 Tyler Lesmann wrote this 2 years, 3 months ago

There seems to be a similar and more complete php api here:

http://code.google.com/p/zimbra-api-php/

#5 Brad Wagoner wrote this 2 years ago

This is awesome! Thank you for posting it, I'm new to SOAP and this was VERY helpful. The requests, however, are not sending messages to the attendees. It looks like they should be added just beneath the 'm' element, but I cant seem to make it work. Any ideas or suggestions? I'll send you my code if that would help!

Thanks again!

Brad

#6 Tyler Lesmann wrote this 2 years ago

That's how it is designed to work in this case. This piece of code was for placing service termination events in the calendars of employees doing the terminating. It was requested that the normal method of using ics be avoided to reduce the noise to signal ratio of the terminators' email inboxes. If you want the owners of the calendars to get messages, it is much easier to send an ics.
http://en.wikipedia.org/wiki/ICalendar

#7 Matt wrote this 1 year, 8 months ago

Is it ok to post this code out on Github? I'd like to try to expand it a bit so I can modify account attributes via SOAP. Maybe make it a gem if I can figure that out.

Matt

#8 Tyler Lesmann wrote this 1 year, 8 months ago

That's fine. Post a link too. :)

#9 Matt wrote this 1 year, 8 months ago

Hmmm...looks like there already is one. Just found this...

http://github.com/magec/rzimbra

I've forked it and am going to try to make it a gem.

Post a comment