Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/changelog.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
---
3.2.1:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't to that - this is the release tool's job. You also create a mess as soon as another PR is released faster.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, i'll instruct my agents.md

api: 3.2.1
state:
release: stable
api: stable
date: 2026-07-17
license:
identifier: LGPL-2.1-only
uri: https://spdx.org/licenses/LGPL-2.1-only.html
notes: "fix(activesync): refuse Draft Modify append-as-new for missing/stale IMAP UIDs (ActiveSync #85)"
3.2.0:
api: 3.2.0
state:
Expand Down
23 changes: 21 additions & 2 deletions lib/Horde/Core/ActiveSync/Driver.php
Original file line number Diff line number Diff line change
Expand Up @@ -2565,26 +2565,45 @@ public function changeMessage($folderid, $id, Horde_ActiveSync_Message_Base $mes
$this->_user,
$this->_version
);
$draft->setDraftMessage($message);

// Load the existing draft before setDraftMessage so
// attachment carry-over works, and so a missing/
// stale Modify ServerId fails before MIME build.
if ($id) {
try {
$draft->getExistingDraftMessage(
$draft_folder,
$id
);
} catch (Horde_ActiveSync_Exception $e) {
// Stale UID from client; treat as new.
// Modify with a missing/stale UID must not
// append a new draft (retries after a lost
// Sync response would otherwise duplicate
// unbounded). Importer short-circuits when
// the prior apply was recorded; this is
// defense in depth when that record is absent.
$this->_logger->notice(
sprintf(
'Draft Modify for missing UID %s in %s; refusing append-as-new.',
$id,
$draft_folder
)
);
$this->_endBuffer();
return false;
}
}

$draft->setDraftMessage($message);

// Append the message and return results.
$results = $draft->append($draft_folder);
$stat['id'] = $results['uid'];
$stat['atchash'] = $results['atchash'];
$stat['conversationid'] = bin2hex($message->subject);
$stat['conversationindex'] = time();

$this->_endBuffer();
return $stat;
}
}
Expand Down
118 changes: 118 additions & 0 deletions test/Unit/ActiveSync/DriverDraftModifyStaleUidTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @author Torben Dannhauer <torben@dannhauer.de>
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
* @subpackage UnitTests
*/

namespace Horde\Core\Test\Unit\ActiveSync;

use Horde\Http\ServerRequest;
use Horde_ActiveSync;
use Horde_ActiveSync_Device;
use Horde_ActiveSync_Imap_Adapter;
use Horde_ActiveSync_Message_AirSyncBaseBody;
use Horde_ActiveSync_Message_Mail;
use Horde_Core_ActiveSync_Auth;
use Horde_Core_ActiveSync_Connector;
use Horde_Core_ActiveSync_Driver;
use Horde_Log_Handler_Null;
use Horde_Log_Logger;
use Horde_Registry;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;

/**
* Defense-in-depth for Issue #85: Draft Modify must not append-as-new when
* the client ServerId is missing/stale on IMAP.
*/
#[CoversMethod(Horde_Core_ActiveSync_Driver::class, 'changeMessage')]
class DriverDraftModifyStaleUidTest extends TestCase
{
public function testDraftModifyMissingUidDoesNotAppend(): void
{
if (!class_exists('Horde_ActiveSync_Message_Mail')) {
$this->markTestSkipped('horde/activesync not available');
}

$folder = 'INBOX/Drafts';
$staleUid = 100;

$imap = $this->getMockBuilder(Horde_ActiveSync_Imap_Adapter::class)
->disableOriginalConstructor()
->onlyMethods(['getImapMessage', 'appendMessage', 'deleteMessages', 'getSpecialMailboxes', 'setLogger'])
->getMock();
$imap->method('getSpecialMailboxes')->willReturn([
Horde_Core_ActiveSync_Driver::SPECIAL_DRAFTS => (object) ['value' => $folder],
]);
$imap->expects($this->once())
->method('getImapMessage')
->with($folder, $staleUid)
->willReturn([]);
$imap->expects($this->never())->method('appendMessage');
$imap->expects($this->never())->method('deleteMessages');

$state = $this->getMockBuilder('Horde_ActiveSync_State_Sql')
->disableOriginalConstructor()
->getMock();
$state->method('setLogger');
$state->method('setBackend');

$connector = $this->getMockBuilder(Horde_Core_ActiveSync_Connector::class)
->disableOriginalConstructor()
->getMock();
$auth = $this->getMockBuilder(Horde_Core_ActiveSync_Auth::class)
->disableOriginalConstructor()
->getMock();
$registry = $this->getMockBuilder(Horde_Registry::class)
->disableOriginalConstructor()
->getMock();

$driver = new Horde_Core_ActiveSync_Driver([
'connector' => $connector,
'auth' => $auth,
'serverrequest' => new ServerRequest('POST', '/'),
'registry' => $registry,
'state' => $state,
'imap' => $imap,
]);
$driver->setLogger(new Horde_Log_Logger(new Horde_Log_Handler_Null()));
$driver->setProtocolVersion(Horde_ActiveSync::VERSION_SIXTEEN);

$userProp = new \ReflectionProperty(
\Horde_ActiveSync_Driver_Base::class,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use "use" statement here.

'_user'
);
$userProp->setAccessible(true);
$userProp->setValue($driver, 'alice@example.com');

$message = new Horde_ActiveSync_Message_Mail([
'protocolversion' => Horde_ActiveSync::VERSION_SIXTEEN,
]);
$message->to = 'alice@example.com';
$message->subject = 'Draft subject';
$body = new Horde_ActiveSync_Message_AirSyncBaseBody([
'protocolversion' => Horde_ActiveSync::VERSION_SIXTEEN,
]);
$body->type = Horde_ActiveSync::BODYPREF_TYPE_PLAIN;
$body->data = 'body';
$message->airsyncbasebody = $body;

$device = $this->createMock(Horde_ActiveSync_Device::class);

$result = $driver->changeMessage($folder, $staleUid, $message, $device);

$this->assertFalse($result);
}
}
Loading