Die Situation: Kunden melden sich auf der Firmen-Homepage mit einem Wunsch-Termin an.
Das ganze hatte ich recht schön auf einer Intranet-Seite in einem Kalender dargestellt, bei dem man dann nur auf den entsprechenden Termin klicken muss, um direkt zum Kassensystem zu gelangen, wo die Kundendaten dann gleich eingetragen werden.

Der Wunsch: Die Geschäftsleitung führt nebenbei noch einen speziellen Outlook-Kalender nur für diese Kunden-Termine und da sollen alle Termine auch nochmals drinnen stehen. Ich bin ja schon heilfroh, dass nicht noch – wie sonst auch an allen Ecken und Enden – einen Excel-Export einrichten muss.

Stunden hatte ich verbracht, eine Möglichkeit zu finden, von einem PHP-Skript aus einen Kalendereintrag einzufügen. Verschiedene vorgefertigte PHP-Klassen hatte ich getestet. Alles erfolglos.
Dabei geht das alles mit wenigen Zeilen Code, sofern die CURL-PHP-Erweiterung auf dem Webserver installiert ist.

Hier mein Code, um einen Kalender-Event in einem Exchange-Kalender hinzuzufügen.

Den XML-Code für den SOAP-Request findet man hilfreicher-weise doch tatsächlich auf der Microsoft-Homepage.

<?php

$xmlstr = <<<XML
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
               xmlns:xsd="http://www.w3.org/2001/XMLSchema"
               xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
               xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
  <soap:Body>
    <CreateItem xmlns="http://schemas.microsoft.com/exchange/services/2006/messages"
                xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types"
                SendMeetingInvitations="SendToAllAndSaveCopy" >
      <SavedItemFolderId>
        <t:DistinguishedFolderId Id="calendar"/>
      </SavedItemFolderId>
      <Items>
        <t:CalendarItem xmlns="http://schemas.microsoft.com/exchange/services/2006/types">
          <Subject>Mein Test-Event</Subject>
          <Body BodyType="Text">Pizza essen, um funktionierende PHP-Exchange-Schnittstelle zu feiern</Body>
          <ReminderIsSet>true</ReminderIsSet>
          <ReminderMinutesBeforeStart>60</ReminderMinutesBeforeStart>
          <Start>2015-08-22T18:00:00</Start>
          <End>2015-08-22T19:00:00</End>
          <IsAllDayEvent>false</IsAllDayEvent>
          <LegacyFreeBusyStatus>Busy</LegacyFreeBusyStatus>
          <Location>Pizzaraia Italia, Hauptstraße 12</Location>
          <RequiredAttendees>
            <Attendee>
              <Mailbox>
                <EmailAddress>azubi@apfel-z.net</EmailAddress>
              </Mailbox>
            </Attendee>
          </RequiredAttendees>
        </t:CalendarItem>
      </Items>
    </CreateItem>
  </soap:Body>
</soap:Envelope>
XML
;

$ch = curl_init('https://**Adresse exchange-server**/EWS/Exchange.asmx');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER,         false);
curl_setopt($ch, CURLOPT_HTTPHEADER,     array('Content-Type: text/xml; charset=utf-8'));
curl_setopt($ch, CURLOPT_POST,           true);
curl_setopt($ch, CURLOPT_HTTP_VERSION,   CURL_HTTP_VERSION_1_1);
curl_setopt($ch, CURLOPT_HTTPAUTH,       CURLAUTH_NTLM );
curl_setopt($ch, CURLOPT_USERPWD,        "** Benutzername Kalenderinhaber**:** Passwort Kalenderinhaber **");
curl_setopt($ch, CURLOPT_POSTFIELDS,     $xmlstr);
$response = curl_exec($ch);            
$info     = curl_getinfo($ch);  // Nur interessant für debugging-Zwecke
$error    = curl_error($ch);    // Nur interessant für debugging-Zwecke
print_r(array($response,$info,$error));
?>

Obwohl bei uns im Netzwerk alles ziemlich Domänen-lastig ist, musste ich für CURLOPT_USERPWD nur meinen eigentlichen Benutzernamen und Passwort (Durch einen Doppelpunkt voneinander getrennt) ohne vorangestellte Domäne angeben.

Auf die einzelnen Tags Subject, IsAllDayEvent & Co. muss ich hoffe ich nicht weiter eingehen. Mit denen kann jeder gerne etwas rumspielen.
Interessant mag für den einen oder anderen vielleicht noch sein, dass man sich bei Start- und Endzeit keine Gedanken über die Zeitzone machen muss. Da hatte ich mir nämlich bei der ICS-Datei-Generierung (zuvor hatte ich eine Kalender-Datei erzeugt, die manuell dem Kalender hinzugefügt werden musste) ziemlich einen abgebrochen.

In $response findet sich dann die XML-Antwort des Exchange-Servers. In meinem Beispiel:

<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
        <s:Header>
                <h:ServerVersionInfo MajorVersion="14" MinorVersion="3" MajorBuildNumber="224" MinorBuildNumber="2" xmlns:h="http://schemas.microsoft.com/exchange/services/2006/types" xmlns="http://schemas.microsoft.com/exchange/services/2006/types" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"/>
        </s:Header>
        <s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
                <m:CreateItemResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
                        <m:ResponseMessages>
                                <m:CreateItemResponseMessage ResponseClass="Success">
                                        <m:ResponseCode>NoError</m:ResponseCode>
                                        <m:Items>
                                                <t:CalendarItem>
                                                        <t:ItemId Id="AQATAE0uUm9waWNAb3otZHJ1Y2suZGUARgAAAzfgsXTvPEtFgCduLrx39LIHAH/iEaY6tWFDtMGuxTT6sywAAANfAAAAf+IRpjq1YUO0wa7FNPqzLAABEnqMgwAAAA==" ChangeKey="DwAAABYAAAB/4hGmOrVhQ7TBrsU0+rMsAAESep4M"/>
                                                </t:CalendarItem>
                                        </m:Items>
                                </m:CreateItemResponseMessage>
                        </m:ResponseMessages>
                </m:CreateItemResponse>
        </s:Body>
</s:Envelope>

Ich rate dazu, die Id und den ChangeKey, die im Tag <t:itemId> zurückgegeben wurden, zu speichern, denn mit diesen beiden Schlüsseln lassen sich die Kalendereinträge später noch ändern und löschen.

Lässt man sich die HTTP-Header mit anzeigen, indem man

curl_setopt($ch, CURLOPT_HEADER, true);
setzt, bekomme ich in der $response zwar immer erst eine 401er-Fehlermeldung (nicht erlaubt) zurück, aber irgendwie scheint die Anfrage wohl doch weiterverarbeitet zu werden und gibt dann am Ende eine 200 (OK) zurück.

Kalendereinträge löschen

Da ich davon ausgehe, dass bald die Nachfrage kommt, ob man Kalendereinträge bei einem abgesagten Termin wieder automatisch löschen könne, habe ich mir mal das dafür notwendige XML-Schema herausgesucht (welches dann im php-Code als $xmlstr hinterlegt werden muss):

<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
      xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <m:DeleteItem DeleteType="MoveToDeletedItems" SendMeetingCancellations="SendToAllAndSaveCopy">
      <m:ItemIds>
        <t:ItemId Id="AQATAE0uUm9waWNAb3otZHJ1Y2suZGUARgAAAzfgsXTvPEtFgCduLrx39LIHAH/iEaY6tWFDtMGuxTT6sywAAANfAAAAf+IRpjq1YUO0wa7FNPqzLAABEnqMgwAAAA==" ChangeKey="DwAAABYAAAB/4hGmOrVhQ7TBrsU0+rMsAAESep4M" />
      </m:ItemIds>
    </m:DeleteItem>
  </soap:Body>
</soap:Envelope>

Id und ChangeKey müssen natürlich entsprechend geändert werden.

SendMeetingCancellations kann alternativ auch SendToNone sein.

Wenn man löschen kann, wieso nicht auch gleich noch ändern?

Klar, geht ebenfalls mit folgender XML-Syntax:

<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
       xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <m:UpdateItem MessageDisposition="SaveOnly" ConflictResolution="AlwaysOverwrite" SendMeetingInvitationsOrCancellations="SendToNone">
      <m:ItemChanges>
        <t:ItemChange>
          <t:ItemId Id="AQATAE0uUm9waWNAb3otZHJ1Y2suZGUARgAAAzfgsXTvPEtFgCduLrx39LIHAH/iEaY6tWFDtMGuxTT6sywAAANfAAAAf+IRpjq1YUO0wa7FNPqzLAABEnqMgwAAAA==" ChangeKey="DwAAABYAAAB/4hGmOrVhQ7TBrsU0+rMsAAESep4M"  />
          <t:Updates>
            <t:SetItemField>
              <t:FieldURI FieldURI="item:Subject" />
              <t:CalendarItem>
                <t:Subject>Mein geänderter Test-Event</t:Subject>
              </t:CalendarItem>
            </t:SetItemField>
            <t:SetItemField>
              <t:FieldURI FieldURI="item:Body" />
              <t:CalendarItem>
                <t:Body BodyType="Text">Neue Informationen</t:Body>
              </t:CalendarItem>
            </t:SetItemField>
            <t:SetItemField>
              <t:FieldURI FieldURI="calendar:Start" />
              <t:CalendarItem>
                <t:Start>2015-08-22T18:00:00</t:Start>
              </t:CalendarItem>
            </t:SetItemField>
            <t:AppendToItemField>
              <t:FieldURI FieldURI="item:Body" />
              <t:Message>
                <t:Body BodyType="Text">Was ich noch dazu sagen wollte...</t:Body>
              </t:Message>
            </t:AppendToItemField>
          </t:Updates>
        </t:ItemChange>
      </m:ItemChanges>
    </m:UpdateItem>
  </soap:Body>
</soap:Envelope>

Sollten mehrere Änderungen fällig sein, scheint es, dass für jede Änderung der gesamte t:SetItemField-Block mit dem entsprechenden Schlüssel wiederholt werden muss.

Außerdem muss auch hier das Attribut BodyType mitgegeben werden, wenn der Body geändert wird.

Beim Body kann es natürlich auch noch interessant sein, Text hinzuzufügen statt ihn zu ändern. Hierfür ist der Tag t:AppendToItemField (statt t:SetItemField) zu verwenden.
t:AppendToItemField und t:SetItemField in einem Aufruf zu verwenden, hatte ich allerdings nicht ausprobiert. Oben nur zu Anschauungszwecken in einem XML kombiniert.

Zu beachten ist weiterhin: Bei Body, Subject ReminderMinutesBeforeStart und ReminderIsSet ist FieldURI="item:***", bei Start, End, IsAllDayEvent und Location ist FieldURI="calendar:***".
Das sieht man natürlich auf einen Blick, wenn man Microsoft's SOAP-Schema studiert, ich bin da allerdings darüber gestolpert, weil ich mich nicht unbedingt durch die ganze wsdl-Datei wühlen wollte (geschweige denn sie überhaupt irgendwo auffinden) und der Struktur etwas mehr Logik unterstellte.

Klasse!

Zwar hatte ich mich über die fertigen PHP-Klassen geärgert, die bei mir nicht funktionierten, aber nun habe ich natürlich selbst eine geschrieben, um zumindest einen neuen Kalendereintrag via PHP anlegen zu können und diesen zu ändern und wieder zu löschen.

Wer interesse daran hat, kann die Klasse hier herunterladen. Anwendungsbeispiel im Code.

Die Klasse ist allerdings sehr rudimentär. Es lassen sich nur Titel, Body, Start, Ende, Erinnerung (ein/aus, Minuten vor Termin) und Ort definieren. Für die meisten Personen sollte dies allerdings ausreichen.