Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F120836589
EWS.php
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
18 KB
Referenced Files
None
Subscribers
None
EWS.php
View Options
<?php
namespace
App\DataMigrator\Driver
;
use
App\DataMigrator\Account
;
use
App\DataMigrator\Engine
;
use
App\DataMigrator\Interface\ExporterInterface
;
use
App\DataMigrator\Interface\Folder
;
use
App\DataMigrator\Interface\ImporterInterface
;
use
App\DataMigrator\Interface\Item
;
use
App\DataMigrator\Interface\ItemSet
;
use
App\DataMigrator\Queue
;
use
garethp\ews\API
;
use
garethp\ews\API\Type
;
use
Illuminate\Support\Facades\Http
;
/**
* Data migration from Exchange (EWS)
*/
class
EWS
implements
ExporterInterface
{
/** @var int Max number of items to migrate in one go */
protected
const
CHUNK_SIZE
=
20
;
/** @var API EWS API object */
public
$api
;
/** @var array Supported folder types */
protected
$folder_classes
=
[
EWS\Appointment
::
FOLDER_TYPE
,
EWS\Contact
::
FOLDER_TYPE
,
EWS\Task
::
FOLDER_TYPE
,
EWS\Email
::
FOLDER_TYPE
,
// TODO: mail and sticky notes are exported as eml files.
// We could use imapsync to synchronize mail, but for notes
// the only option will be to convert them to Kolab format here
// and upload to Kolab via IMAP, I guess
// EWS\Note::FOLDER_TYPE,
// EWS\StickyNote::FOLDER_TYPE,
];
/** @var array Interal folders to skip */
protected
$folder_exceptions
=
[
'AllCategorizedItems'
,
'AllContacts'
,
'AllContactsExtended'
,
'AllPersonMetadata'
,
'AllTodoTasks'
,
'Calendar Version Store'
,
'Document Centric Conversations'
,
'ExternalContacts'
,
'Flagged Emails'
,
'Folder Memberships'
,
'GraphFilesAndWorkingSetSearchFolder'
,
'MyContactsExtended'
,
'Orion Notes'
,
'Outbox'
,
'PersonMetadata'
,
'People I Know'
,
'RelevantContacts'
,
'SharedFilesSearchFolder'
,
'Sharing'
,
'SpoolsPresentSharedItemsSearchFolder'
,
'SpoolsSearchFolder'
,
'To-Do Search'
,
'UserCuratedContacts'
,
'XrmActivityStreamSearch'
,
'XrmCompanySearch'
,
'XrmDealSearch'
,
'XrmSearch'
,
'MS-OLK-AllCalendarItems'
,
'MS-OLK-AllContactItems'
,
'MS-OLK-AllMailItems'
,
// TODO: These are different depending on a user locale and it's not possible
// to switch to English other than changing the user locale in OWA/Exchange.
'Calendar/United States holidays'
,
'Favorites'
,
'My Contacts'
,
'Kalendarz/Polska — dni wolne od pracy'
,
// pl
'Ulubione'
,
// pl
'Moje kontakty'
,
// pl
'Aufgabensuche'
,
// de
'Postausgang'
,
// de
];
/** @var array Map of EWS folder types to Kolab types */
protected
$type_map
=
[
EWS\Appointment
::
FOLDER_TYPE
=>
Engine
::
TYPE_EVENT
,
EWS\Contact
::
FOLDER_TYPE
=>
Engine
::
TYPE_CONTACT
,
EWS\Task
::
FOLDER_TYPE
=>
Engine
::
TYPE_TASK
,
EWS\Email
::
FOLDER_TYPE
=>
Engine
::
TYPE_MAIL
,
];
/** @var Account Account to operate on */
protected
$account
;
/** @var Engine Data migrator engine */
protected
$engine
;
/**
* Object constructor
*/
public
function
__construct
(
Account
$account
,
Engine
$engine
)
{
$this
->
account
=
$account
;
$this
->
engine
=
$engine
;
}
/**
* Server autodiscovery
*/
public
static
function
autodiscover
(
string
$user
,
string
$password
):
?
string
{
// You should never run the Autodiscover more than once.
// It can make between 1 and 5 calls before giving up, or before finding your server,
// depending on how many different attempts it needs to make.
// TODO: Autodiscovery may fail with an exception thrown. Handle this nicely.
// TODO: Looks like this autodiscovery also does not work w/Basic Auth?
$api
=
API\ExchangeAutodiscover
::
getAPI
(
$user
,
$password
);
$server
=
$api
->
getClient
()->
getServer
();
$version
=
$api
->
getClient
()->
getVersion
();
return
sprintf
(
'ews://%s:%s@%s'
,
urlencode
(
$user
),
urlencode
(
$password
),
$server
);
}
/**
* Authenticate to EWS (initialize the EWS client)
*/
public
function
authenticate
():
void
{
if
(!
empty
(
$this
->
account
->
params
[
'client_id'
]))
{
$this
->
api
=
$this
->
authenticateWithOAuth2
(
$this
->
account
->
host
,
$this
->
account
->
username
,
$this
->
account
->
params
[
'client_id'
],
$this
->
account
->
params
[
'client_secret'
],
$this
->
account
->
params
[
'tenant_id'
]
);
}
else
{
// Note: This initializes the client, but not yet connects to the server
// TODO: To know that the credentials work we'll have to do some API call.
$this
->
api
=
$this
->
authenticateWithPassword
(
$this
->
account
->
host
,
$this
->
account
->
username
,
$this
->
account
->
password
,
$this
->
account
->
loginas
);
}
}
/**
* Autodiscover the server and authenticate the user
*/
protected
function
authenticateWithPassword
(
string
$server
,
string
$user
,
string
$password
,
?
string
$loginas
=
null
)
{
// Note: Since 2023-01-01 EWS at Office365 requires OAuth2, no way back to basic auth.
\Log
::
debug
(
"[EWS] Using basic authentication on {$server}..."
);
$options
=
[];
if
(
$loginas
)
{
$options
[
'impersonation'
]
=
$loginas
;
}
$this
->
engine
->
setOption
(
'ews'
,
[
'options'
=>
$options
,
'server'
=>
$server
,
]);
return
API
::
withUsernameAndPassword
(
$server
,
$user
,
$password
,
$this
->
apiOptions
(
$options
));
}
/**
* Authenticate with a token (Office365)
*/
protected
function
authenticateWithToken
(
string
$server
,
string
$user
,
string
$token
,
$expires_at
=
null
)
{
\Log
::
debug
(
"[EWS] Using token authentication on {$server}..."
);
$options
=
[
'impersonation'
=>
$user
];
$this
->
engine
->
setOption
(
'ews'
,
[
'options'
=>
$options
,
'server'
=>
$server
,
'token'
=>
$token
,
'expires_at'
=>
$expires_at
,
]);
return
API
::
withCallbackToken
(
$server
,
$token
,
$this
->
apiOptions
(
$options
));
}
/**
* Authenticate with OAuth2 (Office365) - get the token
*/
protected
function
authenticateWithOAuth2
(
string
$server
,
string
$user
,
string
$client_id
,
string
$client_secret
,
string
$tenant_id
)
{
// See https://github.com/Garethp/php-ews/blob/master/examples/basic/authenticatingWithOAuth.php
// See https://github.com/Garethp/php-ews/issues/236#issuecomment-1292521527
// To register OAuth2 app goto https://entra.microsoft.com > Applications > App registrations
\Log
::
debug
(
"[EWS] Fetching OAuth2 token from {$server}..."
);
$scope
=
'https://outlook.office365.com/.default'
;
$token_uri
=
"https://login.microsoftonline.com/{$tenant_id}/oauth2/v2.0/token"
;
// $authUri = "https://login.microsoftonline.com/{$tenant_id}/oauth2/authorize";
$response
=
Http
::
asForm
()
->
timeout
(
5
)
->
post
(
$token_uri
,
[
'client_id'
=>
$client_id
,
'client_secret'
=>
$client_secret
,
'scope'
=>
$scope
,
'grant_type'
=>
'client_credentials'
,
])
->
throwUnlessStatus
(
200
);
$token
=
$response
->
json
(
'access_token'
);
// Note: Office365 default token expiration time is ~1h,
$expires_in
=
$response
->
json
(
'expires_in'
);
$expires_at
=
now
()->
addSeconds
(
$expires_in
)->
toDateTimeString
();
return
$this
->
authenticateWithToken
(
$server
,
$user
,
$token
,
$expires_at
);
}
/**
* Get folders hierarchy
*/
public
function
getFolders
(
$types
=
[]):
array
{
if
(
empty
(
$types
))
{
$types
=
array_values
(
$this
->
type_map
);
}
// Create FolderClass filter
$search
=
new
Type\OrType
();
foreach
(
$types
as
$type
)
{
$type
=
array_search
(
$type
,
$this
->
type_map
);
$search
->
addContains
(
Type\Contains
::
buildFromArray
([
'FieldURI'
=>
[
Type\FieldURI
::
buildFromArray
([
'FieldURI'
=>
'folder:FolderClass'
]),
],
'Constant'
=>
Type\ConstantValueType
::
buildFromArray
([
'Value'
=>
$type
,
]),
'ContainmentComparison'
=>
'Exact'
,
'ContainmentMode'
=>
'FullString'
,
]));
}
// Get full folders hierarchy (filtered by folder class)
// Use of the filter reduces the response size by excluding system folders
$options
=
[
'Traversal'
=>
'Deep'
,
'Restriction'
=>
[
'Or'
=>
$search
],
];
$folders
=
$this
->
api
->
getChildrenFolders
(
'root'
,
$options
);
$result
=
[];
foreach
(
$folders
as
$folder
)
{
$class
=
$folder
->
getFolderClass
();
$type
=
$this
->
type_map
[
$class
]
??
null
;
// Skip folder types we do not support (need)
if
(
empty
(
$type
)
||
(!
empty
(
$types
)
&&
!
in_array
(
$type
,
$types
)))
{
continue
;
}
// Note: Folder names are localized, even INBOX
$name
=
$fullname
=
$folder
->
getDisplayName
();
$id
=
$folder
->
getFolderId
()->
getId
();
$parentId
=
$folder
->
getParentFolderId
()->
getId
();
// Create folder name with full path
if
(
$parentId
&&
!
empty
(
$result
[
$parentId
]))
{
$fullname
=
$result
[
$parentId
]->
fullname
.
'/'
.
$name
;
}
// Top-level folder, check if it's a special folder we should ignore
// FIXME: Is there a better way to distinguish user folders from system ones?
if
(
in_array
(
$fullname
,
$this
->
folder_exceptions
)
||
str_starts_with
(
$fullname
,
'OwaFV15.1All'
)
)
{
continue
;
}
$result
[
$id
]
=
Folder
::
fromArray
([
'id'
=>
$folder
->
getFolderId
()->
toArray
(
true
),
'total'
=>
$folder
->
getTotalCount
(),
'class'
=>
$class
,
'type'
=>
$this
->
type_map
[
$class
]
??
null
,
'name'
=>
$name
,
'fullname'
=>
$fullname
,
]);
}
return
$result
;
}
/**
* Fetching a folder metadata
*/
public
function
fetchFolder
(
Folder
$folder
):
void
{
// NOP
}
/**
* Fetch a list of folder items
*/
public
function
fetchItemList
(
Folder
$folder
,
$callback
,
ImporterInterface
$importer
):
void
{
// Job processing - initialize environment
$this
->
initEnv
(
$this
->
engine
->
queue
);
// The folder is empty, we can stop here
if
(
empty
(
$folder
->
total
))
{
// TODO: Delete all existing items?
return
;
}
// Get items already imported
// TODO: This might be slow and/or memory expensive, we should consider
// whether storing list of imported items in some cache wouldn't be a better
// solution. Of course, cache would not get changes in the destination account.
$existing
=
$importer
->
getItems
(
$folder
);
// Create X-MS-ID index for easier search in existing items
// Note: For some objects we could use UID (events), but for some we don't have UID in Exchange.
// Also because fetching extra properties here is problematic, we use X-MS-ID.
$existingIndex
=
[];
array_walk
(
$existing
,
static
function
(&
$item
,
$idx
)
use
(&
$existingIndex
)
{
if
(!
empty
(
$item
[
'x-ms-id'
]))
{
[
$id
,
$changeKey
]
=
explode
(
'!'
,
$item
[
'x-ms-id'
]);
$item
[
'changeKey'
]
=
$changeKey
;
$existingIndex
[
$id
]
=
$idx
;
unset
(
$item
[
'x-ms-id'
]);
}
else
{
$existingIndex
[
$idx
]
=
$idx
;
}
}
);
$request
=
[
// Exchange's maximum is 1000
'IndexedPageItemView'
=>
[
'MaxEntriesReturned'
=>
100
,
'Offset'
=>
0
,
'BasePoint'
=>
'Beginning'
],
'ParentFolderIds'
=>
$folder
->
id
,
'Traversal'
=>
'Shallow'
,
'ItemShape'
=>
[
'BaseShape'
=>
'IdOnly'
,
'AdditionalProperties'
=>
[
'FieldURI'
=>
[
[
'FieldURI'
=>
'item:ItemClass'
],
// ['FieldURI' => 'item:Size'],
[
'FieldURI'
=>
'message:InternetMessageId'
],
// For mail only?
],
],
],
];
$request
=
Type
::
buildFromArray
(
$request
);
// Note: It is not possible to get mimeContent with FindItem request
// That's why we first get the list of object identifiers and
// then call GetItem on each separately.
// TODO: It might be feasible to get all properties for object types
// for which we don't use MimeContent, for better performance.
// Request first page
$response
=
$this
->
api
->
getClient
()->
FindItem
(
$request
);
$set
=
new
ItemSet
();
$itemCount
=
0
;
// @phpstan-ignore-next-line
foreach
(
$response
->
getItems
()
as
$item
)
{
$itemCount
++;
if
(
$item
=
$this
->
toItem
(
$item
,
$folder
,
$existing
,
$existingIndex
))
{
$set
->
items
[]
=
$item
;
if
(
count
(
$set
->
items
)
==
self
::
CHUNK_SIZE
)
{
$callback
(
$set
);
$set
=
new
ItemSet
();
}
}
}
// Request other pages until we got all
while
(!
$response
->
isIncludesLastItemInRange
())
{
// @phpstan-ignore-next-line
$response
=
$this
->
api
->
getNextPage
(
$response
);
foreach
(
$response
->
getItems
()
as
$item
)
{
$itemCount
++;
if
(
$item
=
$this
->
toItem
(
$item
,
$folder
,
$existing
,
$existingIndex
))
{
$set
->
items
[]
=
$item
;
if
(
count
(
$set
->
items
)
==
self
::
CHUNK_SIZE
)
{
$callback
(
$set
);
$set
=
new
ItemSet
();
}
}
}
}
if
(
count
(
$set
->
items
))
{
$callback
(
$set
);
}
\Log
::
debug
(
"[EWS] Processed {$itemCount} items"
);
// TODO: Delete items that do not exist anymore?
}
/**
* Fetching an item
*/
public
function
fetchItem
(
Item
$item
):
void
{
// Job processing - initialize environment
$this
->
initEnv
(
$this
->
engine
->
queue
);
if
(
$driver
=
EWS\Item
::
factory
(
$this
,
$item
))
{
$driver
->
processItem
(
$item
);
return
;
}
throw
new
\Exception
(
"Failed to fetch an item from EWS"
);
}
/**
* Get the source account
*/
public
function
getSourceAccount
():
Account
{
return
$this
->
engine
->
source
;
}
/**
* Get the destination account
*/
public
function
getDestinationAccount
():
Account
{
return
$this
->
engine
->
destination
;
}
/**
* Synchronize specified object
*/
protected
function
toItem
(
Type
$item
,
Folder
$folder
,
$existing
,
$existingIndex
):
?
Item
{
$id
=
$item
->
getItemId
()->
toArray
();
$exists
=
null
;
// Detect an existing item, skip if nothing changed
if
(
isset
(
$existingIndex
[
$id
[
'Id'
]]))
{
$idx
=
$existingIndex
[
$id
[
'Id'
]];
if
(
$existing
[
$idx
][
'changeKey'
]
==
$id
[
'ChangeKey'
])
{
\Log
::
debug
(
"[EWS] Skipping over already existing message {$idx} with class {$item->getItemClass()}..."
);
return
null
;
}
$exists
=
$existing
[
$idx
][
'href'
];
}
else
{
$msgid
=
null
;
try
{
$msgid
=
$item
->
getInternetMessageId
();
}
catch
(
\Exception
$e
)
{
// Ignore
}
if
(
isset
(
$existingIndex
[
$msgid
]))
{
// If the messageid already exists, we assume it's the same email.
// Flag/size changes are ignored for now.
// Otherwise we should set uid/size/flags on exists, so the IMAP implementation can pick it up.
\Log
::
debug
(
"[EWS] Skipping over already existing message {$msgid} with class {$item->getItemClass()}..."
);
return
null
;
}
}
if
(!
EWS\Item
::
isValidItem
(
$item
))
{
\Log
::
warning
(
"[EWS] Encountered unhandled item class {$item->getItemClass()}"
);
return
null
;
}
return
Item
::
fromArray
([
'id'
=>
$id
[
'Id'
],
'class'
=>
$item
->
getItemClass
(),
'folder'
=>
$folder
,
'existing'
=>
$exists
,
]);
}
/**
* Set common API options
*/
protected
function
apiOptions
(
array
$options
):
array
{
if
(
empty
(
$options
[
'version'
]))
{
$options
[
'version'
]
=
API\ExchangeWebServices
::
VERSION_2013
;
}
// In debug mode record all responses
if
(
\config
(
'app.debug'
))
{
$options
[
'httpPlayback'
]
=
[
'mode'
=>
'record'
,
'recordLocation'
=>
\storage_path
(
'ews'
),
];
}
// Options for testing
foreach
([
'httpClient'
,
'httpPlayback'
]
as
$opt
)
{
if
((
$val
=
$this
->
engine
->
getOption
(
$opt
))
!==
null
)
{
$options
[
$opt
]
=
$val
;
}
}
return
$options
;
}
/**
* Initialize environment for job execution
*
* @param Queue $queue Queue
*/
protected
function
initEnv
(
Queue
$queue
):
void
{
$ews
=
$queue
->
data
[
'options'
][
'ews'
];
if
(!
empty
(
$ews
[
'token'
]))
{
// TODO: Refresh the token if needed
$this
->
api
=
API
::
withCallbackToken
(
$ews
[
'server'
],
$ews
[
'token'
],
$this
->
apiOptions
(
$ews
[
'options'
])
);
}
else
{
$this
->
api
=
API
::
withUsernameAndPassword
(
$ews
[
'server'
],
$this
->
account
->
username
,
$this
->
account
->
password
,
$this
->
apiOptions
(
$ews
[
'options'
])
);
}
}
}
File Metadata
Details
Attached
Mime Type
text/x-php
Expires
Fri, Apr 24, 1:32 PM (13 h, 51 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18858126
Default Alt Text
EWS.php (18 KB)
Attached To
Mode
rK kolab
Attached
Detach File
Event Timeline