7 Commits

Author SHA1 Message Date
Sebastian 2e9a6a5857 chore(deps): update dependency phpunit/phpunit to v12.5.33
JS Unit Tests / test (pull_request) Successful in 1m1s
Build Test / test (pull_request) Successful in 1m10s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m27s
PHP Unit Tests / test (pull_request) Successful in 1m10s
2026-07-30 03:02:59 +00:00
Sebastian 4cd249d019 fix: attachment upload on send
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-29 21:24:05 -04:00
Sebastian 152a8141d0 fix: logging
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-29 21:20:05 -04:00
Sebastian 5aa95fa329 feat: sync sending identities
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-28 21:50:37 -04:00
Sebastian f0b0f87ce2 fix: primary address saving
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-28 21:02:51 -04:00
Sebastian 75a462ad18 fix: disable auto complete
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-28 20:52:53 -04:00
Sebastian 8af08d8936 refactor: migrate to scoped execution contexts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-27 00:52:29 -04:00
8 changed files with 151 additions and 91 deletions
Generated
+12 -12
View File
@@ -8,16 +8,16 @@
"packages": [
{
"name": "guzzlehttp/guzzle",
"version": "7.15.2",
"version": "7.15.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "744101956d78b7c1384d0cbf379db13e859167bf"
"reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf",
"reference": "744101956d78b7c1384d0cbf379db13e859167bf",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/61443dfb33c62f308ee8add20f45b4d6e4bf8d2f",
"reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f",
"shasum": ""
},
"require": {
@@ -116,7 +116,7 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.15.2"
"source": "https://github.com/guzzle/guzzle/tree/7.15.1"
},
"funding": [
{
@@ -132,7 +132,7 @@
"type": "tidelift"
}
],
"time": "2026-07-26T23:23:20+00:00"
"time": "2026-07-18T11:23:11+00:00"
},
{
"name": "guzzlehttp/promises",
@@ -1442,16 +1442,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.5.31",
"version": "12.5.33",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d"
"reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d",
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b98e028a26c5c5ba7e4a54be96ccf35f2914d184",
"reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184",
"shasum": ""
},
"require": {
@@ -1520,7 +1520,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.31"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.33"
},
"funding": [
{
@@ -1528,7 +1528,7 @@
"type": "other"
}
],
"time": "2026-07-06T14:54:16+00:00"
"time": "2026-07-28T13:58:09+00:00"
},
{
"name": "sebastian/cli-parser",
+59
View File
@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace KTXM\ProviderJmapc\Providers\Mail;
use KTXF\Mail\Object\Address;
use KTXF\Mail\Provider\ProviderBaseInterface;
use KTXF\Mail\Provider\ProviderServiceDiscoverInterface;
use KTXF\Mail\Provider\ProviderServiceMutateInterface;
@@ -142,9 +143,66 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
}
$created = $this->serviceStore->create($tenantId, $userId, $service);
$this->serviceIdentitiesSync($tenantId, $userId, (new Service())->fromStore($created));
return (string) $created['sid'];
}
/**
* Fetches sending identities from the JMAP server and overwrites
* primaryAddress/secondaryAddresses on the service with them.
*/
private function serviceIdentitiesSync(string $tenantId, string $userId, Service $service): void
{
try {
$client = RemoteService::freshClient($service);
$identities = RemoteService::mailService($client)->identityFetch();
// A JMAP server may expose multiple identities sharing the same
// address (e.g. distinct signatures/display names for one
// mailbox) — collapse those down to one entry per address,
// keeping the first label seen.
$addresses = [];
foreach ($identities as $identity) {
if ($identity->address() === null) {
continue;
}
$key = strtolower($identity->address());
if (!isset($addresses[$key])) {
$addresses[$key] = Address::fromArray(['address' => $identity->address(), 'label' => $identity->name()]);
}
}
$addresses = array_values($addresses);
if (empty($addresses)) {
return;
}
$currentAddress = strtolower($service->getPrimaryAddress()->getAddress());
$primaryIndex = 0;
foreach ($addresses as $index => $address) {
if (strtolower($address->getAddress()) === $currentAddress) {
$primaryIndex = $index;
break;
}
}
$secondary = [];
foreach ($addresses as $index => $address) {
if ($index !== $primaryIndex) {
$secondary[] = $address;
}
}
$service->setPrimaryAddress($addresses[$primaryIndex]);
$service->setSecondaryAddresses($secondary);
$this->serviceStore->modify($tenantId, $userId, $service);
} catch (\Throwable) {
// Server may not support urn:ietf:params:jmap:submission, or have
// no identities configured — fall back to what the user entered.
}
}
public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
{
if (!($service instanceof Service)) {
@@ -152,6 +210,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
}
$this->serviceStore->modify($tenantId, $userId, $service);
$this->serviceIdentitiesSync($tenantId, $userId, $service);
return (string) $service->identifier();
}
+28 -3
View File
@@ -206,8 +206,9 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
if (isset($data[self::PROPERTY_IDENTITY])) {
$this->setIdentity($this->freshIdentity(null, $data[self::PROPERTY_IDENTITY]));
}
if (isset($data[self::PROPERTY_PRIMARY_ADDRESS]) && is_string($data[self::PROPERTY_PRIMARY_ADDRESS])) {
$this->setPrimaryAddress(new Address($data[self::PROPERTY_PRIMARY_ADDRESS]));
if (isset($data[self::PROPERTY_PRIMARY_ADDRESS])) {
$value = $data[self::PROPERTY_PRIMARY_ADDRESS];
$this->setPrimaryAddress(is_array($value) ? Address::fromArray($value) : new Address((string)$value));
}
if (isset($data[self::PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::PROPERTY_SECONDARY_ADDRESSES])) {
$this->setSecondaryAddresses(array_map(
@@ -879,7 +880,31 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
);
}
$nativeMessage = $this->normalizeMessageProperties($message)->toJmap();
$nativeProperties = $this->normalizeMessageProperties($message);
$attachments = $nativeProperties->getAttachments();
foreach ($attachments as $attachment) {
if ($attachment->getBlobId() !== null) {
continue;
}
$content = $attachment->getContent();
if ($content === null) {
throw new \InvalidArgumentException(sprintf(
'Attachment "%s" has neither content nor a JMAP blob identifier',
$attachment->getName() ?? 'unnamed',
));
}
$attachment->setBlobId($this->mailService->blobDeposit(
$attachment->getType() ?? 'application/octet-stream',
$content,
));
}
if ($attachments !== []) {
$nativeProperties->setAttachments(...$attachments);
}
$nativeMessage = $nativeProperties->toJmap();
$transportId = $this->mailService->entitySubmitFresh($identityId, $nativeMessage, $preSendTarget->collection(), $postSentTarget->collection());
return new EntitySubmitResult(
+1 -1
View File
@@ -97,7 +97,7 @@ class Provider implements IProviderBase, IProviderServiceMutate
}
// Note: This simplified interface doesn't pass tenantId
// Will need to get it from SessionTenant in actual implementation
// Will need to get it from TenantContextInterface in actual implementation
throw new \RuntimeException('Use Mail Provider interface for service creation');
}
+25
View File
@@ -929,6 +929,19 @@ class RemoteMailService {
$submission->message('#' . $messageCreateId);
$bundle = $this->dataStore->perform([$r0, $r1]);
$messageResponse = $bundle->response(0);
if ($messageResponse instanceof ResponseException) {
throw new Exception($messageResponse->type() . ': ' . $messageResponse->description(), 1);
}
if (method_exists($messageResponse, 'createFailure')) {
$failure = $messageResponse->createFailure($messageCreateId);
if (is_array($failure)) {
$type = $failure['type'] ?? 'unknownError';
$description = $failure['description'] ?? 'Email creation failed';
throw new Exception($type . ': ' . $description, 1);
}
}
$response = $bundle->response(1);
if ($response instanceof ResponseException) {
if ($response->type() === 'unknownMethod') {
@@ -940,6 +953,18 @@ class RemoteMailService {
return $this->extractSubmissionIdentifier($response, $submissionCreateId);
}
/**
* Upload attachment content and return its JMAP blob identifier.
*/
public function blobDeposit(string $type, string $data): string {
$response = json_decode($this->dataStore->upload($this->dataAccount, $type, $data), true);
if (!is_array($response) || !isset($response['blobId']) || !is_string($response['blobId']) || $response['blobId'] === '') {
throw new Exception('JMAP attachment upload did not return a blob identifier', 1);
}
return $response['blobId'];
}
/**
* Submit an existing draft message.
*
+9 -8
View File
@@ -13,7 +13,7 @@ use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\Psr7\HttpFactory;
use JmapClient\Authentication\Basic;
use JmapClient\Client as JmapClient;
use KTXC\Server;
use KTXF\Security\Crypto;
use KTXF\Resource\Provider\ResourceServiceIdentityBasic;
use KTXF\Resource\Provider\ResourceServiceLocationUri;
use KTXM\ProviderJmapc\Providers\Mail\Service as MailService;
@@ -73,10 +73,13 @@ class RemoteService {
}
// debugging
if ($service->getDebug()) {
$logDir = Server::getInstance()?->logDir();
$logDir .= '/jmap/' . $service->identifier() . '.json';
$logDirectory = dirname(__DIR__, 5) . '/var/logs/jmap';
if (!is_dir($logDirectory) && !mkdir($logDirectory, 0775, true) && !is_dir($logDirectory)) {
throw new \RuntimeException(sprintf('Unable to create JMAP log directory: %s', $logDirectory));
}
$logFile = $logDirectory . '/' . $service->identifier() . '.json';
$client->configureTransportLogState(true);
$client->configureTransportLogLocation($logDir);
$client->configureTransportLogLocation($logFile);
}
// return
return $client;
@@ -200,7 +203,7 @@ class RemoteService {
return $service;
}
public static function cookieStoreRetrieve(mixed $id): ?array {
public static function cookieStoreRetrieve(mixed $id, Crypto $crypto): ?array {
$file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . (string)$id . '.jmapc';
@@ -209,7 +212,6 @@ class RemoteService {
}
$data = file_get_contents($file);
$crypto = Server::getInstance()->container()->get(\KTXF\Security\Crypto::class);
$data = $crypto->decrypt($data);
if (!empty($data)) {
@@ -220,13 +222,12 @@ class RemoteService {
}
public static function cookieStoreDeposit(mixed $id, array $value): void {
public static function cookieStoreDeposit(mixed $id, array $value, Crypto $crypto): void {
if (empty($value)) {
return;
}
$crypto = Server::getInstance()->container()->get(\KTXF\Security\Crypto::class);
$data = $crypto->encrypt(json_encode($value));
$file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . (string)$id . '.jmapc';
+2 -2
View File
@@ -274,7 +274,7 @@ async function initiateOAuth() {
variant="outlined"
prepend-inner-icon="mdi-account"
class="mb-4"
autocomplete="username"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:rules="[rules.required]"
@@ -289,7 +289,7 @@ async function initiateOAuth() {
variant="outlined"
prepend-inner-icon="mdi-lock"
class="mb-4"
autocomplete="current-password"
autocomplete="new-password"
:rules="[rules.required]"
/>
</template>
+15 -65
View File
@@ -25,7 +25,7 @@ const settingGroups = [
title: 'Addresses',
value: 'addresses' as const,
icon: 'mdi-at',
description: 'Configure the primary mailbox identity and any sender aliases exposed by this service.'
description: 'View the primary mailbox identity and sender aliases synced from the JMAP server.'
},
{
title: 'Messages',
@@ -71,7 +71,7 @@ watch(
)
watch(
[deleteMode, deleteDestination, primaryAddress, secondaryAddresses],
[deleteMode, deleteDestination],
() => {
const nextService = props.service ?? new ServiceObject()
const nextAuxiliary = {
@@ -83,13 +83,9 @@ watch(
}
if (sameAuxiliary(nextService.auxiliary ?? {}, nextAuxiliary)) {
if (sameAddresses(nextService)) {
return
}
return
}
nextService.primaryAddress = primaryAddress.value.empty ? null : primaryAddress.value
nextService.secondaryAddresses = dedupeAddresses(secondaryAddresses.value)
nextService.auxiliary = nextAuxiliary
emit('update:service', nextService)
},
@@ -111,44 +107,10 @@ function normalizeDeleteDestination(value: string): string {
return trimmedValue.length > 0 ? trimmedValue : 'Trash'
}
function dedupeAddresses(entries: ServiceAddressObject[]): ServiceAddressObject[] {
const populated = entries.filter(entry => !entry.empty)
return populated.filter((entry, index) =>
populated.findIndex(candidate => candidate.matches(entry.address)) === index)
}
function addSecondaryAddress() {
secondaryAddresses.value.push(new ServiceAddressObject())
}
function removeSecondaryAddress(index: number) {
secondaryAddresses.value.splice(index, 1)
}
function validAddress(value: string): boolean | string {
const trimmedValue = value.trim()
return trimmedValue.length === 0 || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedValue) || 'Invalid email address'
}
function sameAuxiliary(current: Record<string, any>, next: Record<string, any>): boolean {
return (current.deleteMode === 'hard' ? 'hard' : 'soft') === next.deleteMode
&& (current.deleteDestination ?? undefined) === (next.deleteDestination ?? undefined)
}
function sameAddresses(service: ServiceObject): boolean {
const nextPrimary = primaryAddress.value.empty ? null : primaryAddress.value
const nextSecondary = dedupeAddresses(secondaryAddresses.value)
return sameAddress(service.primaryAddress, nextPrimary)
&& service.secondaryAddresses.length === nextSecondary.length
&& service.secondaryAddresses.every((entry, index) => sameAddress(entry, nextSecondary[index]))
}
function sameAddress(current: ServiceAddressObject | null, next: ServiceAddressObject | null): boolean {
if (current === null || next === null) {
return current === next
}
return current.equals(next)
}
</script>
<template>
@@ -176,69 +138,57 @@ function sameAddress(current: ServiceAddressObject | null, next: ServiceAddressO
<div class="jmap-settings-card">
<h3 class="text-h6 mb-2">Addresses</h3>
<p class="text-body-2 text-medium-emphasis mb-6">
Configure the primary mailbox identity and any additional sender aliases exposed by this service.
The primary mailbox identity and sender aliases are managed by the JMAP server and synced automatically.
</p>
<div class="text-subtitle-2 mb-2">Primary Address</div>
<div class="d-flex ga-2 mb-6">
<v-text-field
v-model="primaryAddress.label"
:model-value="primaryAddress.label"
label="Display Name"
variant="outlined"
density="compact"
hide-details="auto"
readonly
/>
<v-text-field
v-model="primaryAddress.address"
:model-value="primaryAddress.address"
label="Email Address"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-email-outline"
:rules="[validAddress]"
hide-details="auto"
readonly
/>
</div>
<div class="text-subtitle-2 mb-2">Secondary Addresses</div>
<p v-if="secondaryAddresses.length === 0" class="text-body-2 text-medium-emphasis">
No aliases reported by the server.
</p>
<div
v-for="(entry, index) in secondaryAddresses"
:key="index"
class="d-flex ga-2 mb-2"
>
<v-text-field
v-model="entry.label"
:model-value="entry.label"
label="Display Name"
variant="outlined"
density="compact"
hide-details="auto"
readonly
/>
<v-text-field
v-model="entry.address"
:model-value="entry.address"
label="Email Address"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-email-multiple-outline"
:rules="[validAddress]"
hide-details="auto"
readonly
/>
<v-btn
icon
size="small"
variant="text"
@click="removeSecondaryAddress(index)"
>
<v-icon>mdi-delete-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Remove Alias</v-tooltip>
</v-btn>
</div>
<v-btn
variant="tonal"
prepend-icon="mdi-plus"
@click="addSecondaryAddress"
>
Add Alias
</v-btn>
</div>
</v-window-item>