mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
more thread ui and jmap start
This commit is contained in:
1 parent
a1da945283
commit
4ef4616ee8
46 files changed
+2042
-61
No files matched your search
@@ -89,3 +89,10 @@ APP_DB_LOG_LEVEL=warning
|
||||
APP_CONTAINER_NAME=web
|
||||
|
||||
TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
|
||||
###> lexik/jwt-authentication-bundle ###
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
# Real passphrase lives in .env.local (untracked); config/jwt/*.pem is gitignored.
|
||||
JWT_PASSPHRASE=
|
||||
###< lexik/jwt-authentication-bundle ###
|
||||
@@ -27,3 +27,7 @@
|
||||
docker-compose.override.yml
|
||||
|
||||
truenas.compose.yaml.local
|
||||
|
||||
###> lexik/jwt-authentication-bundle ###
|
||||
/config/jwt/*.pem
|
||||
###< lexik/jwt-authentication-bundle ###
|
||||
Generated
+1
@@ -182,6 +182,7 @@
|
||||
<path value="$PROJECT_DIR$/vendor/tijsverkoyen/css-to-inline-styles" />
|
||||
<path value="$PROJECT_DIR$/vendor/thenetworg/oauth2-azure" />
|
||||
<path value="$PROJECT_DIR$/vendor/firebase/php-jwt" />
|
||||
<path value="$PROJECT_DIR$/vendor/lexik/jwt-authentication-bundle" />
|
||||
</include_path>
|
||||
</component>
|
||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4">
|
||||
|
||||
Generated
+1
@@ -168,6 +168,7 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/tijsverkoyen/css-to-inline-styles" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/firebase/php-jwt" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/thenetworg/oauth2-azure" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/lexik/jwt-authentication-bundle" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
@@ -13,14 +13,13 @@ export default class extends Controller {
|
||||
// floating dock: no fullscreen, no mobile auto-expand, pop-out button,
|
||||
// and the address/subject rows start folded away.
|
||||
inline: { type: Boolean, default: false },
|
||||
// Body characters needed before an autosave may create a draft —
|
||||
// every stray keystroke used to mint one.
|
||||
minChars: { type: Number, default: 5 },
|
||||
// The conversation this window belongs to, for the draft row.
|
||||
thread: Number,
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum body length before an autosave is allowed to create or update a
|
||||
* draft. Every stray keystroke used to mint a draft in the thread.
|
||||
*/
|
||||
static MIN_AUTOSAVE_CHARS = 5;
|
||||
|
||||
#autosaveTimer = null
|
||||
|
||||
connect() {
|
||||
@@ -58,15 +57,11 @@ export default class extends Controller {
|
||||
this._focusCursorAtTop();
|
||||
}
|
||||
|
||||
// Inline: the thread's reply buttons step aside while we're open, and
|
||||
// the draft's own row in the conversation gives way to this editor.
|
||||
// Both are cached — by the time disconnect() runs the card is already
|
||||
// detached and closest() would find nothing.
|
||||
// Inline: the thread's reply buttons step aside while we're open.
|
||||
// Cached — by the time disconnect() runs the card is already detached
|
||||
// and closest() would find nothing.
|
||||
this._zone = this._replyZone();
|
||||
this._zone?.classList.add('composing');
|
||||
|
||||
this._draftRow = this._threadRow();
|
||||
this._draftRow?.classList.add('hidden');
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
@@ -77,7 +72,6 @@ export default class extends Controller {
|
||||
document.removeEventListener('click', this._boundCloseDropdown, { capture: true });
|
||||
document.body.style.overflow = '';
|
||||
this._zone?.classList.remove('composing');
|
||||
this._draftRow?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/** The thread reply zone this card lives in, when rendered inline. */
|
||||
@@ -92,13 +86,6 @@ export default class extends Controller {
|
||||
return this.sendUrlValue.match(/\/send\/(\d+)/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** This draft's row in the open conversation, if it is rendered there. */
|
||||
_threadRow() {
|
||||
const id = true === this.inlineValue ? this._messageId() : null;
|
||||
|
||||
return null === id ? null : document.getElementById(`thread_message_${id}`);
|
||||
}
|
||||
|
||||
// ── Address / subject fields ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -148,6 +135,7 @@ export default class extends Controller {
|
||||
const toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.contentEditable = 'false';
|
||||
toggle.dataset.quoteToggle = '1';
|
||||
toggle.textContent = '··· (show quoted text)';
|
||||
toggle.style.cssText = [
|
||||
'display: inline-block',
|
||||
@@ -369,9 +357,100 @@ export default class extends Controller {
|
||||
|
||||
// ── Close ─────────────────────────────────────────────────────────
|
||||
|
||||
close() {
|
||||
async close() {
|
||||
document.body.style.overflow = '';
|
||||
this.element.closest('turbo-frame').innerHTML = '';
|
||||
|
||||
const frame = this.element.closest('turbo-frame');
|
||||
const id = this._messageId();
|
||||
|
||||
// In-place draft: the frame IS the row, so put the row markup back
|
||||
// rather than leaving a hole in the conversation. Rendered server
|
||||
// side so the snippet reflects what was just saved.
|
||||
if (null !== id && frame?.id.startsWith('compose_draft_')) {
|
||||
const row = frame.closest('[id^="thread_message_"]');
|
||||
const html = await this._fetchRow(id);
|
||||
|
||||
if (null !== row && null !== html) {
|
||||
row.outerHTML = html;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reply box: a draft the autosave created has no row yet — add one,
|
||||
// otherwise it only turns up after a reload.
|
||||
if (null !== id && true === this.inlineValue) {
|
||||
this._insertDraftRow(id);
|
||||
}
|
||||
|
||||
frame.innerHTML = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Really delete the draft — the trash button used to just close the
|
||||
* window and leave it behind.
|
||||
*/
|
||||
async discard() {
|
||||
const id = this._messageId();
|
||||
|
||||
if (null === id) {
|
||||
this.close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.#autosaveTimer);
|
||||
|
||||
const frame = this.element.closest('turbo-frame');
|
||||
const params = new URLSearchParams({ frame: frame?.id ?? 'compose_dock' });
|
||||
|
||||
if (this.hasThreadValue) {
|
||||
params.set('thread', String(this.threadValue));
|
||||
}
|
||||
|
||||
const response = await fetch(`/compose/discard/${id}?${params}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
|
||||
if (false === response.ok) {
|
||||
console.error('[compose] discard failed', response.status);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Turbo.renderStreamMessage(await response.text());
|
||||
}
|
||||
|
||||
_rowUrl(id) {
|
||||
const params = this.hasThreadValue ? `?thread=${this.threadValue}` : '';
|
||||
|
||||
return `/compose/draft-row/${id}${params}`;
|
||||
}
|
||||
|
||||
async _fetchRow(id) {
|
||||
const response = await fetch(this._rowUrl(id), {
|
||||
headers: { 'X-Requested-With': 'fetch' },
|
||||
});
|
||||
|
||||
return true === response.ok ? await response.text() : null;
|
||||
}
|
||||
|
||||
/** Append the row for a freshly created draft to the open conversation. */
|
||||
async _insertDraftRow(id) {
|
||||
const list = this.hasThreadValue
|
||||
? document.getElementById(`thread_messages_${this.threadValue}`)
|
||||
: null;
|
||||
|
||||
if (null === list || null !== document.getElementById(`thread_message_${id}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = await this._fetchRow(id);
|
||||
|
||||
if (null !== html) {
|
||||
list.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -397,6 +476,17 @@ export default class extends Controller {
|
||||
|
||||
_scheduleAutosave() {
|
||||
clearTimeout(this.#autosaveTimer);
|
||||
|
||||
if (false === this._worthSaving()) {
|
||||
this._reportPending();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasSaveStatusTarget && this.saveStatusTarget.textContent.includes('to save')) {
|
||||
this.saveStatusTarget.textContent = '';
|
||||
}
|
||||
|
||||
this.#autosaveTimer = setTimeout(
|
||||
() => this.saveDraft(),
|
||||
this.autosaveDelayValue,
|
||||
@@ -418,6 +508,12 @@ export default class extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing written and nothing saved yet: closing an untouched reply
|
||||
// box must not leave an empty draft behind.
|
||||
if (0 === this._typedLength() && null === this._messageId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = this.hasDraftUrlValue ? this.draftUrlValue : form.action;
|
||||
const status = this.hasSaveStatusTarget ? this.saveStatusTarget : null;
|
||||
const STATUS_CLASSES = ['text-ink-faint', 'text-danger', 'text-success'];
|
||||
@@ -478,12 +574,43 @@ export default class extends Controller {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this._typedLength() >= this.minCharsValue;
|
||||
}
|
||||
|
||||
/** Characters the user has actually written, quote excluded. */
|
||||
_typedLength() {
|
||||
if (false === this.hasBodyTarget) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const clone = this.bodyTarget.cloneNode(true);
|
||||
|
||||
clone.querySelectorAll('[data-quote-wrapped], [data-quoted], blockquote, div[style*="border-top"]')
|
||||
.forEach((node) => node.remove());
|
||||
// The last two selectors cover drafts written before buildQuotedHtml
|
||||
// marked the quote with data-quoted.
|
||||
clone.querySelectorAll(
|
||||
'[data-quote-wrapped], [data-quoted], blockquote, div[style*="border-top"], div[style*="font-size:0.85em"]',
|
||||
).forEach((node) => node.remove());
|
||||
|
||||
return clone.textContent.trim().length >= this.constructor.MIN_AUTOSAVE_CHARS;
|
||||
return clone.textContent.trim().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Below the threshold the status line counts down instead of going quiet,
|
||||
* so it is obvious the draft is not being kept yet.
|
||||
*/
|
||||
_reportPending() {
|
||||
if (false === this.hasSaveStatusTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = this.minCharsValue - this._typedLength();
|
||||
const status = this.saveStatusTarget;
|
||||
|
||||
status.classList.remove('text-danger', 'text-success');
|
||||
status.classList.add('text-ink-faint');
|
||||
status.textContent = missing <= 0
|
||||
? ''
|
||||
: `${missing} more character${1 === missing ? '' : 's'} to save`;
|
||||
}
|
||||
|
||||
_handleSubmit(event) {
|
||||
|
||||
@@ -416,21 +416,23 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
/**
|
||||
* Never let dev-tooling markup ride along into a saved draft — the debug
|
||||
* toolbar used to end up quoted inside the body. Scripts go too: nothing
|
||||
* executable belongs in a mail we are about to send.
|
||||
* Never let dev-tooling markup or editor chrome ride along into a saved
|
||||
* draft: the debug toolbar used to end up quoted inside the body, and the
|
||||
* "show quoted text" toggle is a UI affordance, not part of the mail.
|
||||
* Scripts go too — nothing executable belongs in a mail we're sending.
|
||||
*/
|
||||
_cleanHtml() {
|
||||
const html = this.editorTarget.innerHTML;
|
||||
|
||||
if (!/sf-toolbar|<script|sfwdt/i.test(html)) {
|
||||
if (!/sf-toolbar|<script|sfwdt|data-quote-toggle/i.test(html)) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const clone = this.editorTarget.cloneNode(true);
|
||||
|
||||
clone.querySelectorAll('script, .sf-toolbar, [id^="sfwdt"], [data-frankenphp-hot-reload-preserve]')
|
||||
.forEach((node) => node.remove());
|
||||
clone.querySelectorAll(
|
||||
'script, .sf-toolbar, [id^="sfwdt"], [data-frankenphp-hot-reload-preserve], [data-quote-toggle]',
|
||||
).forEach((node) => node.remove());
|
||||
|
||||
return clone.innerHTML;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"knplabs/knp-paginator-bundle": "*",
|
||||
"league/oauth2-client": "^2.9",
|
||||
"league/oauth2-google": "^5.0",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"phpdocumentor/reflection-docblock": "^6.0",
|
||||
"phpstan/phpdoc-parser": "^2.3.3",
|
||||
"symfony/asset": "8.1.*",
|
||||
|
||||
Generated
+117
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "44f539a2b88e19a64ab7d7d7e1e1a3e2",
|
||||
"content-hash": "3ec86153a9d55f4548464c019abccd01",
|
||||
"packages": [
|
||||
{
|
||||
"name": "carbonphp/carbon-doctrine-types",
|
||||
@@ -2652,6 +2652,122 @@
|
||||
],
|
||||
"time": "2026-03-08T20:05:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lexik/jwt-authentication-bundle",
|
||||
"version": "v3.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/lexik/LexikJWTAuthenticationBundle.git",
|
||||
"reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/lexik/LexikJWTAuthenticationBundle/zipball/60df75dc70ee6f597929cb2f0812adda591dfa4b",
|
||||
"reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-openssl": "*",
|
||||
"lcobucci/jwt": "^5.0",
|
||||
"php": ">=8.2",
|
||||
"symfony/clock": "^6.4|^7.0|^8.0",
|
||||
"symfony/config": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/deprecation-contracts": "^2.4|^3.0",
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-foundation": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0|^8.0",
|
||||
"symfony/property-access": "^6.4|^7.0|^8.0",
|
||||
"symfony/security-bundle": "^6.4|^7.0|^8.0",
|
||||
"symfony/translation-contracts": "^1.0|^2.0|^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"api-platform/core": "^3.0|^4.0",
|
||||
"rector/rector": "^1.2",
|
||||
"symfony/browser-kit": "^6.4|^7.0|^8.0",
|
||||
"symfony/console": "^6.4|^7.0|^8.0",
|
||||
"symfony/dom-crawler": "^6.4|^7.0|^8.0",
|
||||
"symfony/filesystem": "^6.4|^7.0|^8.0",
|
||||
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
|
||||
"symfony/phpunit-bridge": "^6.4|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0|^8.0",
|
||||
"symfony/yaml": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"gesdinet/jwt-refresh-token-bundle": "Implements a refresh token system over Json Web Tokens in Symfony",
|
||||
"spomky-labs/lexik-jose-bridge": "Provides a JWT Token encoder with encryption support"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Lexik\\Bundle\\JWTAuthenticationBundle\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jeremy Barthe",
|
||||
"email": "j.barthe@lexik.fr",
|
||||
"homepage": "https://github.com/jeremyb"
|
||||
},
|
||||
{
|
||||
"name": "Nicolas Cabot",
|
||||
"email": "n.cabot@lexik.fr",
|
||||
"homepage": "https://github.com/slashfan"
|
||||
},
|
||||
{
|
||||
"name": "Cedric Girard",
|
||||
"email": "c.girard@lexik.fr",
|
||||
"homepage": "https://github.com/cedric-g"
|
||||
},
|
||||
{
|
||||
"name": "Dev Lexik",
|
||||
"email": "dev@lexik.fr",
|
||||
"homepage": "https://github.com/lexik"
|
||||
},
|
||||
{
|
||||
"name": "Robin Chalas",
|
||||
"email": "robin.chalas@gmail.com",
|
||||
"homepage": "https://github.com/chalasr"
|
||||
},
|
||||
{
|
||||
"name": "Lexik Community",
|
||||
"homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle/graphs/contributors"
|
||||
}
|
||||
],
|
||||
"description": "This bundle provides JWT authentication for your Symfony REST API",
|
||||
"homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle",
|
||||
"keywords": [
|
||||
"Authentication",
|
||||
"JWS",
|
||||
"api",
|
||||
"bundle",
|
||||
"jwt",
|
||||
"rest",
|
||||
"symfony"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/lexik/LexikJWTAuthenticationBundle/issues",
|
||||
"source": "https://github.com/lexik/LexikJWTAuthenticationBundle/tree/v3.2.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/chalasr",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/lexik/jwt-authentication-bundle",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-20T17:47:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.10.0",
|
||||
|
||||
@@ -17,4 +17,5 @@ return [
|
||||
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
|
||||
Symfony\UX\Autocomplete\AutocompleteBundle::class => ['all' => true],
|
||||
Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
];
|
||||
@@ -21,6 +21,12 @@ doctrine:
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
Jmap:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Jmap/State'
|
||||
prefix: 'App\Jmap\State'
|
||||
alias: Jmap
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
lexik_jwt_authentication:
|
||||
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
|
||||
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||
@@ -14,6 +14,11 @@ security:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
jmap:
|
||||
pattern: ^/(jmap|\.well-known/jmap)
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
jwt: ~
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_user_provider
|
||||
@@ -26,6 +31,8 @@ security:
|
||||
access_control:
|
||||
- { path: ^/gmail/push, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/webhook/graph, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/\.well-known/jmap, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/jmap, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/, roles: ROLE_USER }
|
||||
|
||||
@@ -1070,6 +1070,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* filter?: scalar|Param|null, // Default: "({uid_key}={user_identifier})"
|
||||
* password_attribute?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* lexik_jwt?: array{
|
||||
* class?: scalar|Param|null, // Default: "Lexik\\Bundle\\JWTAuthenticationBundle\\Security\\User\\JWTUser"
|
||||
* },
|
||||
* }>,
|
||||
* firewalls?: array<string, array{ // Default: []
|
||||
* pattern?: scalar|Param|null,
|
||||
@@ -1128,6 +1131,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* provider?: scalar|Param|null,
|
||||
* user?: scalar|Param|null, // Default: "REMOTE_USER"
|
||||
* },
|
||||
* jwt?: array{
|
||||
* provider?: scalar|Param|null, // Default: null
|
||||
* authenticator?: scalar|Param|null, // Default: "lexik_jwt_authentication.security.jwt_authenticator"
|
||||
* },
|
||||
* login_link?: array{
|
||||
* check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
|
||||
* check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
|
||||
@@ -1527,6 +1534,91 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* default_cookie_lifetime?: int|Param, // Default lifetime of the cookie containing the JWT, in seconds. Defaults to the value of "framework.session.cookie_lifetime". // Default: null
|
||||
* enable_profiler?: bool|Param, // Deprecated: The child node "enable_profiler" at path "mercure.enable_profiler" is deprecated. // Enable Symfony Web Profiler integration.
|
||||
* }
|
||||
* @psalm-type LexikJwtAuthenticationConfig = array{
|
||||
* public_key?: scalar|Param|null, // The key used to sign tokens (useless for HMAC). If not set, the key will be automatically computed from the secret key. // Default: null
|
||||
* additional_public_keys?: list<scalar|Param|null>,
|
||||
* secret_key?: scalar|Param|null, // The key used to sign tokens. It can be a raw secret (for HMAC), a raw RSA/ECDSA key or the path to a file itself being plaintext or PEM. // Default: null
|
||||
* pass_phrase?: scalar|Param|null, // The key passphrase (useless for HMAC) // Default: ""
|
||||
* token_ttl?: scalar|Param|null, // Default: 3600
|
||||
* allow_no_expiration?: bool|Param, // Allow tokens without "exp" claim (i.e. indefinitely valid, no lifetime) to be considered valid. Caution: usage of this should be rare. // Default: false
|
||||
* clock_skew?: scalar|Param|null, // Default: 0
|
||||
* encoder?: array{
|
||||
* service?: scalar|Param|null, // Default: "lexik_jwt_authentication.encoder.lcobucci"
|
||||
* signature_algorithm?: scalar|Param|null, // Default: "RS256"
|
||||
* },
|
||||
* user_id_claim?: scalar|Param|null, // Default: "username"
|
||||
* token_extractors?: array{
|
||||
* authorization_header?: bool|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* prefix?: scalar|Param|null, // Default: "Bearer"
|
||||
* name?: scalar|Param|null, // Default: "Authorization"
|
||||
* },
|
||||
* cookie?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* name?: scalar|Param|null, // Default: "BEARER"
|
||||
* },
|
||||
* query_parameter?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* name?: scalar|Param|null, // Default: "bearer"
|
||||
* },
|
||||
* split_cookie?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* cookies?: list<scalar|Param|null>,
|
||||
* },
|
||||
* },
|
||||
* remove_token_from_body_when_cookies_used?: scalar|Param|null, // Default: true
|
||||
* set_cookies?: array<string, array{ // Default: []
|
||||
* lifetime?: scalar|Param|null, // The cookie lifetime. If null, the "token_ttl" option value will be used // Default: null
|
||||
* samesite?: "none"|"lax"|"strict"|Param, // Default: "lax"
|
||||
* path?: scalar|Param|null, // Default: "/"
|
||||
* domain?: scalar|Param|null, // Default: null
|
||||
* secure?: scalar|Param|null, // Default: true
|
||||
* httpOnly?: scalar|Param|null, // Default: true
|
||||
* partitioned?: scalar|Param|null, // Default: false
|
||||
* split?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* api_platform?: bool|array{ // API Platform compatibility: add check_path in OpenAPI documentation.
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* check_path?: scalar|Param|null, // The login check path to add in OpenAPI. // Default: null
|
||||
* username_path?: scalar|Param|null, // The path to the username in the JSON body. // Default: null
|
||||
* password_path?: scalar|Param|null, // The path to the password in the JSON body. // Default: null
|
||||
* },
|
||||
* access_token_issuance?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* signature?: array{
|
||||
* algorithm?: scalar|Param|null, // The algorithm use to sign the access tokens.
|
||||
* key?: scalar|Param|null, // The signature key. It shall be JWK encoded.
|
||||
* },
|
||||
* encryption?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* key_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
||||
* content_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
||||
* key?: scalar|Param|null, // The encryption key. It shall be JWK encoded.
|
||||
* },
|
||||
* },
|
||||
* access_token_verification?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* signature?: array{
|
||||
* header_checkers?: list<scalar|Param|null>,
|
||||
* claim_checkers?: list<scalar|Param|null>,
|
||||
* mandatory_claims?: list<scalar|Param|null>,
|
||||
* allowed_algorithms?: list<scalar|Param|null>,
|
||||
* keyset?: scalar|Param|null, // The signature keyset. It shall be JWKSet encoded.
|
||||
* },
|
||||
* encryption?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* continue_on_decryption_failure?: bool|Param, // If enable, non-encrypted tokens or tokens that failed during decryption or verification processes are accepted. // Default: false
|
||||
* header_checkers?: list<scalar|Param|null>,
|
||||
* allowed_key_encryption_algorithms?: list<scalar|Param|null>,
|
||||
* allowed_content_encryption_algorithms?: list<scalar|Param|null>,
|
||||
* keyset?: scalar|Param|null, // The encryption keyset. It shall be JWKSet encoded.
|
||||
* },
|
||||
* },
|
||||
* blocklist_token?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app"
|
||||
* },
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1543,6 +1635,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* symfonycasts_tailwind?: SymfonycastsTailwindConfig,
|
||||
* knp_paginator?: KnpPaginatorConfig,
|
||||
* mercure?: MercureConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1562,6 +1655,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* symfonycasts_tailwind?: SymfonycastsTailwindConfig,
|
||||
* knp_paginator?: KnpPaginatorConfig,
|
||||
* mercure?: MercureConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
@@ -1579,6 +1673,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* symfonycasts_tailwind?: SymfonycastsTailwindConfig,
|
||||
* knp_paginator?: KnpPaginatorConfig,
|
||||
* mercure?: MercureConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
@@ -1597,6 +1692,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* symfonycasts_tailwind?: SymfonycastsTailwindConfig,
|
||||
* knp_paginator?: KnpPaginatorConfig,
|
||||
* mercure?: MercureConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
@@ -9,3 +9,9 @@
|
||||
|
||||
controllers:
|
||||
resource: routing.controllers
|
||||
|
||||
jmap:
|
||||
resource:
|
||||
path: ../src/Jmap/Controller/
|
||||
namespace: App\Jmap\Controller
|
||||
type: attribute
|
||||
@@ -52,6 +52,13 @@ services:
|
||||
App\Service\Mail\MailSenderRegistry:
|
||||
arguments:
|
||||
$senders: !tagged_iterator app.mail_sender
|
||||
|
||||
App\Jmap\Method\MethodRegistry:
|
||||
arguments:
|
||||
$methods: !tagged_iterator app.jmap_method
|
||||
|
||||
_instanceof:
|
||||
App\Domain\Interface\AccountSyncerInterface:
|
||||
tags: ['app.account_syncer']
|
||||
App\Jmap\Method\JmapMethod:
|
||||
tags: ['app.jmap_method']
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260726155733 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create jmap_change_log, the append-only JMAP state/change log.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE jmap_change_log (sequence INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, account_id INT NOT NULL, object_type VARCHAR(32) NOT NULL, entity_id VARCHAR(64) NOT NULL, change_type VARCHAR(16) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (sequence))');
|
||||
$this->addSql('CREATE INDEX idx_jmap_change_scan ON jmap_change_log (account_id, object_type, sequence)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP TABLE jmap_change_log');
|
||||
}
|
||||
}
|
||||
@@ -280,6 +280,63 @@ class ComposeController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation row for a draft, as its own turbo-frame. Used to put
|
||||
* the row back after the in-place editor closes, and to materialise the
|
||||
* row for a draft the autosave created while the reply box was open.
|
||||
*/
|
||||
#[Route('/draft-row/{id}', name: 'draft_row', methods: ['GET'])]
|
||||
public function draftRow(Message $message): Response
|
||||
{
|
||||
$this->assertOwnership($message);
|
||||
|
||||
return $this->render('mail/_thread_message.html.twig', [
|
||||
'message' => $message,
|
||||
'expanded' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard: the trash button in the compose window really deletes the
|
||||
* draft instead of just closing the window on top of it.
|
||||
*/
|
||||
#[Route('/discard/{id}', name: 'discard', methods: ['POST'])]
|
||||
public function discard(Request $request, Message $message): Response
|
||||
{
|
||||
$this->assertOwnership($message);
|
||||
|
||||
if (false === $message->isDraft() || null !== $message->getSentAt()) {
|
||||
throw $this->createAccessDeniedException('Only unsent drafts can be discarded.');
|
||||
}
|
||||
|
||||
$ctx = $this->composeContext($request);
|
||||
$messageId = $message->getId();
|
||||
$thread = $message->getThread();
|
||||
|
||||
$this->em->remove($message);
|
||||
|
||||
// Recount from the association, never from the stored counter — it
|
||||
// drifts, and the thread cascades removes to every message in it.
|
||||
// An emptied thread is left in place: harmless, and the sync layer
|
||||
// reuses it if the conversation comes back.
|
||||
if (null !== $thread) {
|
||||
$thread->setMessageCount(max(0, $thread->getMessages()->count() - 1));
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
if (null !== $thread) {
|
||||
$this->threadLabelSynchronizer->sync($thread);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
return $this->turboStream('compose/_discard.stream.html.twig', [
|
||||
'messageId' => $messageId,
|
||||
'frame' => $ctx['frame'],
|
||||
'inline' => $ctx['inline'],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -297,12 +354,15 @@ class ComposeController extends AbstractController
|
||||
*/
|
||||
private function composeContext(Request $request): array
|
||||
{
|
||||
$frame = (string) $request->query->get('frame', self::DOCK_FRAME);
|
||||
$inline = self::DOCK_FRAME !== $frame;
|
||||
$frame = (string) $request->query->get('frame', self::DOCK_FRAME);
|
||||
|
||||
// compose_inline is the reply box at the foot of the thread;
|
||||
// compose_draft_{id} is a draft being edited in place, in its own row.
|
||||
$inline = 1 === preg_match('/^compose_(inline|draft_\d+)$/', $frame);
|
||||
|
||||
return [
|
||||
'inline' => $inline,
|
||||
'frame' => $inline ? self::INLINE_FRAME : self::DOCK_FRAME,
|
||||
'frame' => $inline ? $frame : self::DOCK_FRAME,
|
||||
'thread' => $request->query->has('thread') ? $request->query->getInt('thread') : null,
|
||||
'replyTo' => $request->query->has('reply_to') ? $request->query->getInt('reply_to') : null,
|
||||
];
|
||||
@@ -403,6 +463,36 @@ class ComposeController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's own writing as plain text: everything before the quoted
|
||||
* original (marked with data-quoted by buildQuotedHtml). Drives the draft
|
||||
* snippet in the conversation and the message list.
|
||||
*/
|
||||
private function plainTextBody(?string $html): ?string
|
||||
{
|
||||
if (null === $html || '' === trim($html)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// data-quoted is the current marker; the other two cut drafts written
|
||||
// before it existed (attribution div / bare blockquote).
|
||||
$ownPart = preg_split(
|
||||
'/<div[^>]*data-quote|<div[^>]*font-size:0\.85em|<blockquote/i',
|
||||
$html,
|
||||
2,
|
||||
)[0];
|
||||
|
||||
$text = html_entity_decode(
|
||||
strip_tags(preg_replace('/<(br|\/p|\/div)[^>]*>/i', "\n", $ownPart) ?? $ownPart),
|
||||
ENT_QUOTES | ENT_HTML5,
|
||||
'UTF-8',
|
||||
);
|
||||
|
||||
$text = trim(preg_replace('/[ \t]*\R\s*/u', "\n", $text) ?? $text);
|
||||
|
||||
return '' === $text ? null : $text;
|
||||
}
|
||||
|
||||
private function turboStream(string $template, array $params): Response
|
||||
{
|
||||
return $this->render($template, $params, new Response(
|
||||
@@ -711,6 +801,7 @@ class ComposeController extends AbstractController
|
||||
// the message it becomes) renders blank in the conversation until the
|
||||
// sent copy comes back from IMAP.
|
||||
$this->bodySanitizer->sanitize($message);
|
||||
$message->setBodyText($this->plainTextBody($message->getBodyHtml()));
|
||||
|
||||
$this->em->persist($message);
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Jmap\Protocol\Exception\InvalidRequestException;
|
||||
use App\Jmap\Protocol\Exception\UnknownCapabilityException;
|
||||
use App\Jmap\Protocol\JmapProcessor;
|
||||
use App\Jmap\Protocol\JmapRequest;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
|
||||
/**
|
||||
* The single JMAP API endpoint. Every read and write flows through here as an
|
||||
* ordered list of method calls (RFC 8620 §3.3).
|
||||
*/
|
||||
final class JmapApiController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly JmapProcessor $processor,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/jmap/api', name: 'jmap_api', methods: ['POST'])]
|
||||
public function api(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
try {
|
||||
$payload = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return $this->problem('urn:ietf:params:jmap:error:notJSON', 'The request body is not valid JSON.');
|
||||
}
|
||||
|
||||
if (false === is_array($payload)) {
|
||||
return $this->problem('urn:ietf:params:jmap:error:notRequest', 'The request is not a JSON object.');
|
||||
}
|
||||
|
||||
try {
|
||||
$jmapRequest = JmapRequest::fromArray($payload);
|
||||
$response = $this->processor->process($jmapRequest, $user);
|
||||
} catch (InvalidRequestException $exception) {
|
||||
return $this->problem('urn:ietf:params:jmap:error:notRequest', $exception->getMessage());
|
||||
} catch (UnknownCapabilityException $exception) {
|
||||
return $this->problem('urn:ietf:params:jmap:error:unknownCapability', $exception->getMessage());
|
||||
}
|
||||
|
||||
return new JsonResponse($response->toArray());
|
||||
}
|
||||
|
||||
private function problem(string $type, string $detail): JsonResponse
|
||||
{
|
||||
$response = new JsonResponse(
|
||||
[
|
||||
'type' => $type,
|
||||
'status' => Response::HTTP_BAD_REQUEST,
|
||||
'detail' => $detail,
|
||||
],
|
||||
Response::HTTP_BAD_REQUEST,
|
||||
);
|
||||
$response->headers->set('Content-Type', 'application/problem+json');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Jmap\Session\SessionBuilder;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
|
||||
/**
|
||||
* Session discovery. Clients fetch /.well-known/jmap first (RFC 8620 §2.2),
|
||||
* then use the apiUrl from the returned Session object for all subsequent calls.
|
||||
*/
|
||||
final class JmapSessionController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionBuilder $sessionBuilder,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/.well-known/jmap', name: 'jmap_well_known', methods: ['GET'])]
|
||||
#[Route('/jmap/session', name: 'jmap_session', methods: ['GET'])]
|
||||
public function session(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
return new JsonResponse($this->sessionBuilder->build($user));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Method\Core;
|
||||
|
||||
use App\Jmap\Method\JmapMethod;
|
||||
use App\Jmap\Protocol\JmapContext;
|
||||
|
||||
/**
|
||||
* "Core/echo" (RFC 8620 §4). Returns its arguments unchanged. Handy as a
|
||||
* spec-compliant smoke test that the request envelope, dispatch loop and
|
||||
* auth firewall all work end to end.
|
||||
*/
|
||||
final class CoreEchoMethod implements JmapMethod
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'Core/echo';
|
||||
}
|
||||
|
||||
public function handle(array $arguments, JmapContext $context): array
|
||||
{
|
||||
return $arguments;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Method;
|
||||
|
||||
use App\Jmap\Protocol\JmapContext;
|
||||
|
||||
/**
|
||||
* A single JMAP method (e.g. "Email/get"). Implementations are autoconfigured
|
||||
* with the "app.jmap_method" tag and indexed by name() in the MethodRegistry.
|
||||
*
|
||||
* Throw App\Jmap\Protocol\Exception\MethodException for method-level errors;
|
||||
* the processor turns those into inline ["error", ...] responses.
|
||||
*/
|
||||
interface JmapMethod
|
||||
{
|
||||
/**
|
||||
* The fully-qualified method name as sent by clients, e.g. "Mailbox/get".
|
||||
*/
|
||||
public function name(): string;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $arguments Result references already resolved.
|
||||
*
|
||||
* @return array<string,mixed> The method's result object.
|
||||
*/
|
||||
public function handle(array $arguments, JmapContext $context): array;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Method;
|
||||
|
||||
/**
|
||||
* Indexes every tagged JmapMethod by its name() for O(1) dispatch.
|
||||
*/
|
||||
final class MethodRegistry
|
||||
{
|
||||
/** @var array<string,JmapMethod> */
|
||||
private array $methods = [];
|
||||
|
||||
/**
|
||||
* @param iterable<JmapMethod> $methods
|
||||
*/
|
||||
public function __construct(iterable $methods)
|
||||
{
|
||||
foreach ($methods as $method) {
|
||||
$this->methods[$method->name()] = $method;
|
||||
}
|
||||
}
|
||||
|
||||
public function get(string $name): ?JmapMethod
|
||||
{
|
||||
return $this->methods[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function names(): array
|
||||
{
|
||||
return array_keys($this->methods);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Protocol;
|
||||
|
||||
/**
|
||||
* JMAP capability URNs (RFC 8620 / RFC 8621) advertised by this server.
|
||||
*/
|
||||
final class Capability
|
||||
{
|
||||
public const string CORE = 'urn:ietf:params:jmap:core';
|
||||
public const string MAIL = 'urn:ietf:params:jmap:mail';
|
||||
public const string SUBMISSION = 'urn:ietf:params:jmap:submission';
|
||||
|
||||
/**
|
||||
* Capabilities a client is currently allowed to declare in "using".
|
||||
* Grow this list as new object types come online.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const array SUPPORTED = [
|
||||
self::CORE,
|
||||
self::MAIL,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Protocol\Exception;
|
||||
|
||||
/**
|
||||
* A request-level problem: the whole request is rejected before any method runs.
|
||||
* Maps to the JMAP problem type "urn:ietf:params:jmap:error:notRequest".
|
||||
*/
|
||||
final class InvalidRequestException extends JmapException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Protocol\Exception;
|
||||
|
||||
abstract class JmapException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Protocol\Exception;
|
||||
|
||||
/**
|
||||
* A method-level error. Unlike request-level exceptions this does NOT abort the
|
||||
* whole request; the processor catches it and emits an inline
|
||||
* ["error", {type, ...extra}, callId] response for the offending method call,
|
||||
* then continues with the next call.
|
||||
*/
|
||||
final class MethodException extends JmapException
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $extra
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $errorType,
|
||||
string $message = '',
|
||||
public readonly array $extra = [],
|
||||
) {
|
||||
if ('' === $message) {
|
||||
$message = $errorType;
|
||||
}
|
||||
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Protocol\Exception;
|
||||
|
||||
/**
|
||||
* The client declared a capability in "using" that this server does not support.
|
||||
* Maps to "urn:ietf:params:jmap:error:unknownCapability".
|
||||
*/
|
||||
final class UnknownCapabilityException extends JmapException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Protocol;
|
||||
|
||||
use App\Jmap\Protocol\Exception\InvalidRequestException;
|
||||
|
||||
/**
|
||||
* A single JMAP method call, i.e. one entry of "methodCalls":
|
||||
* [ "Email/get", { ...arguments }, "callId" ].
|
||||
*/
|
||||
final class Invocation
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $arguments
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) string $name,
|
||||
public private(set) array $arguments,
|
||||
public private(set) string $callId,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
if (count($data) !== 3) {
|
||||
throw new InvalidRequestException('Each method call must be a triplet of [name, arguments, callId].');
|
||||
}
|
||||
|
||||
[$name, $arguments, $callId] = array_values($data);
|
||||
|
||||
if (false === is_string($name) || false === is_array($arguments) || false === is_string($callId)) {
|
||||
throw new InvalidRequestException('Malformed method call triplet.');
|
||||
}
|
||||
|
||||
return new self($name, $arguments, $callId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $result
|
||||
*
|
||||
* @return array{0:string,1:array<string,mixed>,2:string}
|
||||
*/
|
||||
public function toResult(array $result): array
|
||||
{
|
||||
return [$this->name, $result, $this->callId];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $extra
|
||||
*
|
||||
* @return array{0:string,1:array<string,mixed>,2:string}
|
||||
*/
|
||||
public function toError(string $type, array $extra = []): array
|
||||
{
|
||||
return ['error', array_merge(['type' => $type], $extra), $this->callId];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Protocol;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* Mutable per-request state threaded through every method call:
|
||||
* - the authenticated user (used to scope and validate accountId);
|
||||
* - the accumulated method responses, so a later call can resolve a
|
||||
* back-reference (e.g. "#ids") against an earlier call's result;
|
||||
* - the createdIds map (client-supplied creation ids plus any minted
|
||||
* server-side during this request), so "#creationId" references resolve.
|
||||
*/
|
||||
final class JmapContext
|
||||
{
|
||||
/** @var list<array{0:string,1:array<string,mixed>,2:string}> */
|
||||
private array $responses = [];
|
||||
|
||||
/** @var array<string,string> */
|
||||
private array $createdIds;
|
||||
|
||||
/**
|
||||
* @param array<string,string> $createdIds
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly User $user,
|
||||
array $createdIds = [],
|
||||
) {
|
||||
$this->createdIds = $createdIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0:string,1:array<string,mixed>,2:string} $response
|
||||
*/
|
||||
public function addResponse(array $response): void
|
||||
{
|
||||
$this->responses[] = $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{0:string,1:array<string,mixed>,2:string}>
|
||||
*/
|
||||
public function responses(): array
|
||||
{
|
||||
return $this->responses;
|
||||
}
|
||||
|
||||
public function recordCreatedId(string $creationId, string $realId): void
|
||||
{
|
||||
$this->createdIds[$creationId] = $realId;
|
||||
}
|
||||
|
||||
public function resolveCreationId(string $creationId): ?string
|
||||
{
|
||||
return $this->createdIds[$creationId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public function createdIds(): array
|
||||
{
|
||||
return $this->createdIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Protocol;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Jmap\Method\MethodRegistry;
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
use App\Jmap\Protocol\Exception\UnknownCapabilityException;
|
||||
use App\Jmap\State\StateManager;
|
||||
|
||||
/**
|
||||
* The heart of the server: runs each method call in order, resolving
|
||||
* back-references against earlier results, and assembles the response
|
||||
* envelope. A failing method call yields an inline error and does not
|
||||
* abort the remaining calls.
|
||||
*/
|
||||
final class JmapProcessor
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MethodRegistry $registry,
|
||||
private readonly ReferenceResolver $referenceResolver,
|
||||
private readonly StateManager $stateManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(JmapRequest $request, User $user): JmapResponse
|
||||
{
|
||||
$this->assertCapabilities($request->using);
|
||||
|
||||
$context = new JmapContext($user, $request->createdIds);
|
||||
|
||||
foreach ($request->methodCalls as $invocation) {
|
||||
$this->processInvocation($invocation, $context);
|
||||
}
|
||||
|
||||
return new JmapResponse(
|
||||
$context->responses(),
|
||||
$this->stateManager->sessionState($user),
|
||||
$context->createdIds(),
|
||||
);
|
||||
}
|
||||
|
||||
private function processInvocation(Invocation $invocation, JmapContext $context): void
|
||||
{
|
||||
$method = $this->registry->get($invocation->name);
|
||||
|
||||
if (null === $method) {
|
||||
$context->addResponse($invocation->toError('unknownMethod'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$arguments = $this->referenceResolver->resolve($invocation->arguments, $context);
|
||||
$result = $method->handle($arguments, $context);
|
||||
$context->addResponse($invocation->toResult($result));
|
||||
} catch (MethodException $exception) {
|
||||
$context->addResponse($invocation->toError($exception->errorType, $exception->extra));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $using
|
||||
*/
|
||||
private function assertCapabilities(array $using): void
|
||||
{
|
||||
foreach ($using as $capability) {
|
||||
if (false === in_array($capability, Capability::SUPPORTED, true)) {
|
||||
throw new UnknownCapabilityException(sprintf('Capability "%s" is not supported.', $capability));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Protocol;
|
||||
|
||||
use App\Jmap\Protocol\Exception\InvalidRequestException;
|
||||
|
||||
/**
|
||||
* A parsed JMAP request envelope (RFC 8620 §3.3).
|
||||
*/
|
||||
final class JmapRequest
|
||||
{
|
||||
/**
|
||||
* @param list<string> $using
|
||||
* @param list<Invocation> $methodCalls
|
||||
* @param array<string,string> $createdIds
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) array $using,
|
||||
public private(set) array $methodCalls,
|
||||
public private(set) array $createdIds = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
public static function fromArray(array $payload): self
|
||||
{
|
||||
$using = $payload['using'] ?? null;
|
||||
$rawCalls = $payload['methodCalls'] ?? null;
|
||||
|
||||
if (false === is_array($using) || false === is_array($rawCalls)) {
|
||||
throw new InvalidRequestException('A JMAP request requires "using" and "methodCalls" arrays.');
|
||||
}
|
||||
|
||||
$methodCalls = [];
|
||||
|
||||
foreach ($rawCalls as $rawCall) {
|
||||
if (false === is_array($rawCall)) {
|
||||
throw new InvalidRequestException('Each method call must be an array.');
|
||||
}
|
||||
|
||||
$methodCalls[] = Invocation::fromArray($rawCall);
|
||||
}
|
||||
|
||||
$createdIds = $payload['createdIds'] ?? [];
|
||||
|
||||
if (false === is_array($createdIds)) {
|
||||
throw new InvalidRequestException('"createdIds" must be an object when present.');
|
||||
}
|
||||
|
||||
return new self(array_values($using), $methodCalls, $createdIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Protocol;
|
||||
|
||||
/**
|
||||
* A JMAP response envelope (RFC 8620 §3.4).
|
||||
*/
|
||||
final class JmapResponse
|
||||
{
|
||||
/**
|
||||
* @param list<array{0:string,1:array<string,mixed>,2:string}> $methodResponses
|
||||
* @param array<string,string> $createdIds
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) array $methodResponses,
|
||||
public private(set) string $sessionState,
|
||||
public private(set) array $createdIds = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$payload = [
|
||||
'methodResponses' => $this->methodResponses,
|
||||
'sessionState' => $this->sessionState,
|
||||
];
|
||||
|
||||
if (count($this->createdIds) > 0) {
|
||||
$payload['createdIds'] = $this->createdIds;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Protocol;
|
||||
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
|
||||
/**
|
||||
* Resolves JMAP result references (RFC 8620 §3.7). Any argument whose key
|
||||
* begins with "#" carries a { resultOf, name, path } reference that is
|
||||
* evaluated against a previous method call's result using a restricted
|
||||
* JSON Pointer (RFC 6901) that additionally supports the "*" wildcard.
|
||||
*/
|
||||
final class ReferenceResolver
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $arguments
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function resolve(array $arguments, JmapContext $context): array
|
||||
{
|
||||
$resolved = [];
|
||||
|
||||
foreach ($arguments as $key => $value) {
|
||||
if (true === str_starts_with((string) $key, '#')) {
|
||||
$realKey = substr((string) $key, 1);
|
||||
$resolved[$realKey] = $this->resolveReference($value, $context);
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolved[$key] = $value;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
private function resolveReference(mixed $reference, JmapContext $context): mixed
|
||||
{
|
||||
if (false === is_array($reference)) {
|
||||
throw new MethodException('invalidResultReference', 'Result reference must be an object.');
|
||||
}
|
||||
|
||||
$resultOf = $reference['resultOf'] ?? null;
|
||||
$name = $reference['name'] ?? null;
|
||||
$path = $reference['path'] ?? null;
|
||||
|
||||
if (false === is_string($resultOf) || false === is_string($name) || false === is_string($path)) {
|
||||
throw new MethodException('invalidResultReference', 'Result reference is missing resultOf, name or path.');
|
||||
}
|
||||
|
||||
$result = $this->findResult($resultOf, $name, $context);
|
||||
|
||||
if (null === $result) {
|
||||
throw new MethodException('invalidResultReference', 'Referenced method call produced no usable result.');
|
||||
}
|
||||
|
||||
return $this->evaluatePointer($result, $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function findResult(string $callId, string $name, JmapContext $context): ?array
|
||||
{
|
||||
foreach ($context->responses() as $response) {
|
||||
[$responseName, $responseResult, $responseCallId] = $response;
|
||||
|
||||
if ($responseCallId !== $callId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('error' === $responseName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($responseName !== $name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $responseResult;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function evaluatePointer(mixed $value, string $pointer): mixed
|
||||
{
|
||||
if ('' === $pointer) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$tokens = explode('/', $pointer);
|
||||
|
||||
if ('' === $tokens[0]) {
|
||||
array_shift($tokens);
|
||||
}
|
||||
|
||||
return $this->applyTokens($value, $tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
private function applyTokens(mixed $value, array $tokens): mixed
|
||||
{
|
||||
if (count($tokens) === 0) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$token = array_shift($tokens);
|
||||
|
||||
if ('*' === $token) {
|
||||
if (false === is_array($value) || false === array_is_list($value)) {
|
||||
throw new MethodException('invalidResultReference', 'Wildcard "*" applied to a non-array value.');
|
||||
}
|
||||
|
||||
$collected = [];
|
||||
|
||||
foreach ($value as $item) {
|
||||
$mapped = $this->applyTokens($item, $tokens);
|
||||
|
||||
if (true === is_array($mapped) && true === array_is_list($mapped)) {
|
||||
foreach ($mapped as $entry) {
|
||||
$collected[] = $entry;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$collected[] = $mapped;
|
||||
}
|
||||
|
||||
return $collected;
|
||||
}
|
||||
|
||||
$token = str_replace(['~1', '~0'], ['/', '~'], $token);
|
||||
|
||||
if (false === is_array($value) || false === array_key_exists($token, $value)) {
|
||||
throw new MethodException('invalidResultReference', sprintf('Path segment "%s" not found in result.', $token));
|
||||
}
|
||||
|
||||
return $this->applyTokens($value[$token], $tokens);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# plMail JMAP — base set (phase 1 + 2)
|
||||
|
||||
A compliant JMAP core envelope plus the state/change-tracking foundation that
|
||||
every `/changes` method builds on. This is the skeleton; object methods
|
||||
(`Mailbox/*`, `Email/*`, `EmailSubmission/set`) plug into it next.
|
||||
|
||||
## What's here
|
||||
|
||||
**Protocol (the dispatch machinery — built once)**
|
||||
- `Protocol/Invocation`, `JmapRequest`, `JmapResponse` — the request/response envelope.
|
||||
- `Protocol/JmapProcessor` — runs each method call in order; a failing call
|
||||
yields an inline error and does not abort the rest.
|
||||
- `Protocol/ReferenceResolver` — resolves `#foo` result references against
|
||||
earlier results via a restricted JSON Pointer, including the `/*` wildcard.
|
||||
- `Protocol/JmapContext` — per-request user + accumulated responses + createdIds.
|
||||
- `Protocol/Capability` — advertised capability URNs (grow `SUPPORTED` as you go).
|
||||
|
||||
**Methods (each object type is a thin plug-in)**
|
||||
- `Method/JmapMethod` — the interface; implementations are tagged and indexed
|
||||
by `name()` in `MethodRegistry`.
|
||||
- `Method/Core/CoreEchoMethod` — `Core/echo`, a spec-compliant smoke test.
|
||||
|
||||
**Session**
|
||||
- `Session/SessionBuilder` — the Session object. One JMAP account per connected
|
||||
mail account. **This is the only file coupled to the mail-account entity.**
|
||||
|
||||
**State (phase 2 — the sync engine)**
|
||||
- `State/ChangeLog` (entity) — append-only log; autoincrement PK *is* the state token.
|
||||
- `State/ChangeLogRepository` — scan/aggregate/prune queries.
|
||||
- `State/StateManager` — the façade the app calls: `recordCreated/Updated/Destroyed`
|
||||
on the write side, `stateFor` and `changesSince` on the read side.
|
||||
- `State/ChangeSet`, `JmapObjectType`, `ChangeType`.
|
||||
|
||||
## Integration points (resolved — see `wiring.md` for what was applied)
|
||||
|
||||
- **Mail-account shape** — `SessionBuilder` and `StateManager::sessionState()`
|
||||
iterate `User::getAccounts()` and call `Account::getId()` /
|
||||
`Account::getEmail()`. A JMAP `accountId` is the `App\Entity\Account` id as a
|
||||
string. These two files are the only ones coupled to the account entity.
|
||||
- **User class** — controllers/`JmapContext` typehint `App\Entity\User`.
|
||||
- **Auth** — `lexik/jwt-authentication-bundle` on a stateless `jmap` firewall
|
||||
ahead of `main`. Any bearer authenticator resolving `#[CurrentUser]` works.
|
||||
|
||||
## Smoke test
|
||||
|
||||
Mint a token, then run the two calls:
|
||||
|
||||
```bash
|
||||
docker compose exec php php bin/console lexik:jwt:generate-token mail@pluetzner.de
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -sk https://localhost/jmap/session -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
curl -sk https://localhost/jmap/api \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"using":["urn:ietf:params:jmap:core"],
|
||||
"methodCalls":[["Core/echo",{"hello":"world"},"c0"]]}'
|
||||
```
|
||||
|
||||
Expected: the session object, then
|
||||
`{"methodResponses":[["Core/echo",{"hello":"world"},"c0"]],"sessionState":"..."}`.
|
||||
|
||||
## Next (phase 3+)
|
||||
|
||||
`Mailbox/get|query|changes` first (your `Label` maps onto JMAP `Mailbox`),
|
||||
then `Email/*`, then `EmailSubmission/set`, then blob download (reuses
|
||||
`AttachmentController` + the `gmail://` resolver). Add the abstract
|
||||
`Get/Changes/Query/Set` base methods when the second object type makes the
|
||||
shared shape worth extracting.
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Session;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Jmap\Protocol\Capability;
|
||||
use App\Jmap\State\StateManager;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Builds the JMAP Session object (RFC 8620 §2) returned from /jmap/session and
|
||||
* /.well-known/jmap. One JMAP account is exposed per connected mail account, so
|
||||
* a single login enumerates all of the user's mail; a unified inbox is a
|
||||
* client-side concern (one Email/query per account, merged in the client).
|
||||
*
|
||||
* NOTE — the ONLY place coupled to the mail-account entity shape. It uses:
|
||||
* User::getAccounts(): iterable<Account>
|
||||
* Account::getId(): ?int
|
||||
* Account::getEmail(): ?string
|
||||
* Adjust these three calls if the accessors move and nothing else changes.
|
||||
*/
|
||||
final class SessionBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StateManager $stateManager,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function build(User $user): array
|
||||
{
|
||||
$apiUrl = $this->urlGenerator->generate('jmap_api', [], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
$base = (string) preg_replace('#/jmap/api$#', '', $apiUrl);
|
||||
|
||||
$accounts = [];
|
||||
$primaryId = null;
|
||||
|
||||
foreach ($user->getAccounts() as $account) {
|
||||
$accountId = (string) $account->getId();
|
||||
$primaryId ??= $accountId;
|
||||
|
||||
$accounts[$accountId] = [
|
||||
'name' => (string) $account->getEmail(),
|
||||
'isPersonal' => true,
|
||||
'isReadOnly' => false,
|
||||
'accountCapabilities' => [
|
||||
Capability::MAIL => $this->mailAccountCapabilities(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$primaryAccounts = [];
|
||||
|
||||
if (null !== $primaryId) {
|
||||
$primaryAccounts[Capability::MAIL] = $primaryId;
|
||||
}
|
||||
|
||||
// Empty JMAP maps must serialise as {} rather than [].
|
||||
$accountsValue = new \stdClass();
|
||||
|
||||
if (count($accounts) > 0) {
|
||||
$accountsValue = $accounts;
|
||||
}
|
||||
|
||||
$primaryAccountsValue = new \stdClass();
|
||||
|
||||
if (count($primaryAccounts) > 0) {
|
||||
$primaryAccountsValue = $primaryAccounts;
|
||||
}
|
||||
|
||||
return [
|
||||
'capabilities' => [
|
||||
Capability::CORE => $this->coreCapabilities(),
|
||||
Capability::MAIL => new \stdClass(),
|
||||
],
|
||||
'accounts' => $accountsValue,
|
||||
'primaryAccounts' => $primaryAccountsValue,
|
||||
'username' => $user->getUserIdentifier(),
|
||||
'apiUrl' => $apiUrl,
|
||||
'downloadUrl' => $base.'/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
|
||||
'uploadUrl' => $base.'/jmap/upload/{accountId}',
|
||||
'eventSourceUrl' => $base.'/jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}',
|
||||
'state' => $this->stateManager->sessionState($user),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function coreCapabilities(): array
|
||||
{
|
||||
return [
|
||||
'maxSizeUpload' => 50_000_000,
|
||||
'maxConcurrentUpload' => 4,
|
||||
'maxSizeRequestObject' => 10_000_000,
|
||||
'maxConcurrentRequests' => 4,
|
||||
'maxCallsInRequest' => 32,
|
||||
'maxObjectsInGet' => 500,
|
||||
'maxObjectsInSet' => 500,
|
||||
'collationAlgorithms' => [
|
||||
'i;ascii-numeric',
|
||||
'i;ascii-casemap',
|
||||
'i;unicode-casemap',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function mailAccountCapabilities(): array
|
||||
{
|
||||
return [
|
||||
'maxMailboxesPerEmail' => null,
|
||||
'maxMailboxDepth' => null,
|
||||
'maxSizeMailboxName' => 255,
|
||||
'maxSizeAttachmentsPerEmail' => 50_000_000,
|
||||
'emailQuerySortOptions' => ['receivedAt', 'from', 'to', 'subject', 'size'],
|
||||
'mayCreateTopLevelMailbox' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\State;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* Append-only log of object mutations. The autoincrement primary key is the
|
||||
* monotonic state token: a client's "state" for a given account+objectType is
|
||||
* the highest sequence recorded for it, and "/changes" simply returns rows
|
||||
* with sequence > sinceState.
|
||||
*
|
||||
* accountId is stored as a scalar (not a ManyToOne) on purpose: these rows are
|
||||
* written from long-running sync handlers where holding entity references
|
||||
* across flush() is the documented footgun. A plain id sidesteps it entirely.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ChangeLogRepository::class)]
|
||||
#[ORM\Table(name: 'jmap_change_log')]
|
||||
#[ORM\Index(name: 'idx_jmap_change_scan', columns: ['account_id', 'object_type', 'sequence'])]
|
||||
class ChangeLog
|
||||
{
|
||||
// integer (32-bit) is intentional: the log is pruned, so it never approaches
|
||||
// the limit. If churn ever warrants bigint, switch this to type: 'bigint' AND
|
||||
// retype the property to ?string (Doctrine hydrates bigint as a string).
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
public private(set) ?int $sequence = null;
|
||||
|
||||
#[ORM\Column(name: 'account_id')]
|
||||
public private(set) int $accountId;
|
||||
|
||||
#[ORM\Column(name: 'object_type', length: 32, enumType: JmapObjectType::class)]
|
||||
public private(set) JmapObjectType $objectType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', length: 64)]
|
||||
public private(set) string $entityId;
|
||||
|
||||
#[ORM\Column(name: 'change_type', length: 16, enumType: ChangeType::class)]
|
||||
public private(set) ChangeType $changeType;
|
||||
|
||||
#[ORM\Column(name: 'created_at')]
|
||||
public private(set) \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(
|
||||
int $accountId,
|
||||
JmapObjectType $objectType,
|
||||
string $entityId,
|
||||
ChangeType $changeType,
|
||||
) {
|
||||
$this->accountId = $accountId;
|
||||
$this->objectType = $objectType;
|
||||
$this->entityId = $entityId;
|
||||
$this->changeType = $changeType;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\State;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ChangeLog>
|
||||
*/
|
||||
final class ChangeLogRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ChangeLog::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state token for an account+objectType: the highest sequence
|
||||
* recorded, or 0 when nothing has ever been logged for it.
|
||||
*/
|
||||
public function latestSequence(int $accountId, JmapObjectType $type): int
|
||||
{
|
||||
$result = $this->createQueryBuilder('c')
|
||||
->select('MAX(c.sequence)')
|
||||
->where('c.accountId = :accountId')
|
||||
->andWhere('c.objectType = :type')
|
||||
->setParameter('accountId', $accountId)
|
||||
->setParameter('type', $type->value)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
if (null === $result) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lowest sequence still retained for an account+objectType, or 0 when
|
||||
* none remain. Used to detect a state token that predates pruned history.
|
||||
*/
|
||||
public function oldestSequence(int $accountId, JmapObjectType $type): int
|
||||
{
|
||||
$result = $this->createQueryBuilder('c')
|
||||
->select('MIN(c.sequence)')
|
||||
->where('c.accountId = :accountId')
|
||||
->andWhere('c.objectType = :type')
|
||||
->setParameter('accountId', $accountId)
|
||||
->setParameter('type', $type->value)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
if (null === $result) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows strictly newer than $since for an account+objectType, ordered by
|
||||
* sequence ascending. Fetches one more than $limit so the caller can tell
|
||||
* whether more changes remain beyond the returned window.
|
||||
*
|
||||
* @return list<ChangeLog>
|
||||
*/
|
||||
public function changesSince(int $accountId, JmapObjectType $type, int $since, int $limit): array
|
||||
{
|
||||
return $this->createQueryBuilder('c')
|
||||
->where('c.accountId = :accountId')
|
||||
->andWhere('c.objectType = :type')
|
||||
->andWhere('c.sequence > :since')
|
||||
->setParameter('accountId', $accountId)
|
||||
->setParameter('type', $type->value)
|
||||
->setParameter('since', $since)
|
||||
->orderBy('c.sequence', 'ASC')
|
||||
->setMaxResults($limit + 1)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune rows older than $before for an account+objectType. Callers that
|
||||
* prune must accept that clients holding a state token below the new floor
|
||||
* will be told to resync (cannotCalculateChanges).
|
||||
*/
|
||||
public function pruneOlderThan(int $accountId, JmapObjectType $type, \DateTimeImmutable $before): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('c')
|
||||
->delete()
|
||||
->where('c.accountId = :accountId')
|
||||
->andWhere('c.objectType = :type')
|
||||
->andWhere('c.createdAt < :before')
|
||||
->setParameter('accountId', $accountId)
|
||||
->setParameter('type', $type->value)
|
||||
->setParameter('before', $before)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\State;
|
||||
|
||||
/**
|
||||
* The computed result of a "/changes" call: the collapsed created / updated /
|
||||
* destroyed id partitions between two state tokens. Feeds directly into
|
||||
* Mailbox/changes and Email/changes once those methods land.
|
||||
*/
|
||||
final class ChangeSet
|
||||
{
|
||||
/**
|
||||
* @param list<string> $created
|
||||
* @param list<string> $updated
|
||||
* @param list<string> $destroyed
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $oldState,
|
||||
public readonly string $newState,
|
||||
public readonly bool $hasMoreChanges,
|
||||
public readonly array $created,
|
||||
public readonly array $updated,
|
||||
public readonly array $destroyed,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toResult(string $accountId): array
|
||||
{
|
||||
return [
|
||||
'accountId' => $accountId,
|
||||
'oldState' => $this->oldState,
|
||||
'newState' => $this->newState,
|
||||
'hasMoreChanges' => $this->hasMoreChanges,
|
||||
'created' => $this->created,
|
||||
'updated' => $this->updated,
|
||||
'destroyed' => $this->destroyed,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\State;
|
||||
|
||||
enum ChangeType: string
|
||||
{
|
||||
case Created = 'created';
|
||||
case Updated = 'updated';
|
||||
case Destroyed = 'destroyed';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\State;
|
||||
|
||||
/**
|
||||
* The JMAP object types whose changes are tracked in the change log.
|
||||
* Values match the JMAP type name so they read naturally in queries/logs.
|
||||
*/
|
||||
enum JmapObjectType: string
|
||||
{
|
||||
case Mailbox = 'Mailbox';
|
||||
case Email = 'Email';
|
||||
case Thread = 'Thread';
|
||||
case EmailSubmission = 'EmailSubmission';
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\State;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* The single façade the rest of the app uses for JMAP state.
|
||||
*
|
||||
* Write side: sync handlers and /set methods record mutations here. record()
|
||||
* only persist()s the log row; it deliberately does NOT flush() so it commits
|
||||
* inside the caller's existing unit of work (e.g. a Gmail batch flush).
|
||||
*
|
||||
* Read side: /get returns stateFor(); /changes calls changesSince().
|
||||
*/
|
||||
final class StateManager
|
||||
{
|
||||
/**
|
||||
* How many change rows a single /changes call may return before it sets
|
||||
* hasMoreChanges and asks the client to page. Kept modest for mobile.
|
||||
*/
|
||||
private const int DEFAULT_MAX_CHANGES = 256;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ChangeLogRepository $changeLogRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function recordCreated(int $accountId, JmapObjectType $type, string $entityId): void
|
||||
{
|
||||
$this->record($accountId, $type, ChangeType::Created, $entityId);
|
||||
}
|
||||
|
||||
public function recordUpdated(int $accountId, JmapObjectType $type, string $entityId): void
|
||||
{
|
||||
$this->record($accountId, $type, ChangeType::Updated, $entityId);
|
||||
}
|
||||
|
||||
public function recordDestroyed(int $accountId, JmapObjectType $type, string $entityId): void
|
||||
{
|
||||
$this->record($accountId, $type, ChangeType::Destroyed, $entityId);
|
||||
}
|
||||
|
||||
private function record(int $accountId, JmapObjectType $type, ChangeType $changeType, string $entityId): void
|
||||
{
|
||||
$this->entityManager->persist(new ChangeLog($accountId, $type, $entityId, $changeType));
|
||||
}
|
||||
|
||||
/**
|
||||
* The opaque state token for an account+objectType, as returned by /get.
|
||||
*/
|
||||
public function stateFor(int $accountId, JmapObjectType $type): string
|
||||
{
|
||||
return (string) $this->changeLogRepository->latestSequence($accountId, $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the created/updated/destroyed partition since a client's token.
|
||||
*
|
||||
* @throws MethodException "cannotCalculateChanges" when the token predates
|
||||
* retained history, or is not a recognised token.
|
||||
*/
|
||||
public function changesSince(
|
||||
int $accountId,
|
||||
JmapObjectType $type,
|
||||
?string $sinceState,
|
||||
?int $maxChanges = null,
|
||||
): ChangeSet {
|
||||
if (null === $sinceState) {
|
||||
throw new MethodException('invalidArguments', 'sinceState is required.');
|
||||
}
|
||||
|
||||
if (false === ctype_digit($sinceState)) {
|
||||
throw new MethodException('cannotCalculateChanges', 'Unrecognised state token.');
|
||||
}
|
||||
|
||||
$limit = $maxChanges ?? self::DEFAULT_MAX_CHANGES;
|
||||
|
||||
if ($limit < 1) {
|
||||
$limit = self::DEFAULT_MAX_CHANGES;
|
||||
}
|
||||
|
||||
$since = (int) $sinceState;
|
||||
$latest = $this->changeLogRepository->latestSequence($accountId, $type);
|
||||
|
||||
if ($since === $latest) {
|
||||
return new ChangeSet((string) $since, (string) $latest, false, [], [], []);
|
||||
}
|
||||
|
||||
$oldest = $this->changeLogRepository->oldestSequence($accountId, $type);
|
||||
|
||||
if (0 !== $oldest && $since < $oldest - 1) {
|
||||
throw new MethodException('cannotCalculateChanges', 'State token predates retained history.');
|
||||
}
|
||||
|
||||
$rows = $this->changeLogRepository->changesSince($accountId, $type, $since, $limit);
|
||||
$hasMore = count($rows) > $limit;
|
||||
|
||||
if (true === $hasMore) {
|
||||
$rows = array_slice($rows, 0, $limit);
|
||||
}
|
||||
|
||||
return $this->collapse($since, $rows, $hasMore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a run of log rows into the three JMAP id partitions. An id created
|
||||
* and destroyed within the window is omitted (the client never saw it).
|
||||
*
|
||||
* @param list<ChangeLog> $rows
|
||||
*/
|
||||
private function collapse(int $since, array $rows, bool $hasMore): ChangeSet
|
||||
{
|
||||
$newState = $since;
|
||||
|
||||
/** @var array<string,array{created:bool,destroyed:bool}> $seen */
|
||||
$seen = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$newState = (int) $row->sequence;
|
||||
$id = $row->entityId;
|
||||
|
||||
if (false === array_key_exists($id, $seen)) {
|
||||
$seen[$id] = ['created' => false, 'destroyed' => false];
|
||||
}
|
||||
|
||||
if (ChangeType::Created === $row->changeType) {
|
||||
$seen[$id]['created'] = true;
|
||||
}
|
||||
|
||||
if (ChangeType::Destroyed === $row->changeType) {
|
||||
$seen[$id]['destroyed'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$created = [];
|
||||
$updated = [];
|
||||
$destroyed = [];
|
||||
|
||||
foreach ($seen as $id => $flags) {
|
||||
if (true === $flags['created'] && true === $flags['destroyed']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (true === $flags['created']) {
|
||||
$created[] = (string) $id;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (true === $flags['destroyed']) {
|
||||
$destroyed[] = (string) $id;
|
||||
continue;
|
||||
}
|
||||
|
||||
$updated[] = (string) $id;
|
||||
}
|
||||
|
||||
return new ChangeSet(
|
||||
(string) $since,
|
||||
(string) $newState,
|
||||
$hasMore,
|
||||
array_values($created),
|
||||
array_values($updated),
|
||||
array_values($destroyed),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session-level state string (RFC 8620 §2): a digest of the account
|
||||
* configuration. It changes when accounts are added/removed/renamed, which
|
||||
* is the signal for a client to refetch the Session object.
|
||||
*/
|
||||
public function sessionState(User $user): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach ($user->getAccounts() as $account) {
|
||||
$parts[] = (string) $account->getId();
|
||||
}
|
||||
|
||||
sort($parts);
|
||||
|
||||
return substr(hash('xxh128', implode('|', $parts)), 0, 16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# Wiring — applied 2026-07-26
|
||||
|
||||
Record of what was changed outside `src/Jmap/` to bring this online. All six
|
||||
steps are done; kept as a map of the touch points.
|
||||
|
||||
Two steps beyond the original four were needed:
|
||||
|
||||
- **Doctrine mapping (step 5)** — the `App` mapping only covers `src/Entity`,
|
||||
so `App\Jmap\State\ChangeLog` was invisible to the ORM until a second mapping
|
||||
was registered.
|
||||
- **Lexik was not installed (step 3)** — the bundle, keypair, and passphrase
|
||||
handling had to be set up, not just the firewall.
|
||||
|
||||
## 1. Routing — register the attribute routes
|
||||
|
||||
`config/routes.yaml` (append):
|
||||
|
||||
```yaml
|
||||
jmap:
|
||||
resource:
|
||||
path: ../src/Jmap/Controller/
|
||||
namespace: App\Jmap\Controller
|
||||
type: attribute
|
||||
```
|
||||
|
||||
## 2. Service wiring — tag the method handlers
|
||||
|
||||
`config/services.yaml`, under `services:` (the `_instanceof` block can be merged
|
||||
with any you already have):
|
||||
|
||||
```yaml
|
||||
_instanceof:
|
||||
App\Jmap\Method\JmapMethod:
|
||||
tags: ['app.jmap_method']
|
||||
|
||||
App\Jmap\Method\MethodRegistry:
|
||||
arguments:
|
||||
$methods: !tagged_iterator app.jmap_method
|
||||
```
|
||||
|
||||
Everything else in `src/Jmap/` is autowired by the default `App\` resource,
|
||||
provided your `services.yaml` autoconfigures `../src/` (the Symfony default). If
|
||||
your `App\` resource excludes non-`Entity`/`Controller` dirs, make sure
|
||||
`src/Jmap/` is not excluded.
|
||||
|
||||
## 3. Security — bearer-token firewall over /jmap
|
||||
|
||||
LexikJWTAuthenticationBundle was installed for this (`composer require
|
||||
lexik/jwt-authentication-bundle`, then `lexik:jwt:generate-keypair`). The recipe
|
||||
writes a generated `JWT_PASSPHRASE` into the tracked `.env`; it was moved to
|
||||
`.env.local` and left blank in `.env`. `config/jwt/*.pem` is gitignored by the
|
||||
recipe. **Any other environment needs its own keypair + matching passphrase.**
|
||||
|
||||
`config/packages/security.yaml` (firewalls, ordered before your web firewall):
|
||||
|
||||
```yaml
|
||||
firewalls:
|
||||
jmap:
|
||||
pattern: ^/(jmap|\.well-known/jmap)
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
jwt: ~
|
||||
|
||||
# ... your existing web firewall below ...
|
||||
|
||||
access_control:
|
||||
- { path: ^/\.well-known/jmap, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/jmap, roles: IS_AUTHENTICATED_FULLY }
|
||||
```
|
||||
|
||||
The QR-login endpoints (`/auth/qr/*`, to be built) sit on their own rules:
|
||||
`create` and `confirm` require the authenticated web session; `claim` and the
|
||||
token-issuing exchange are anonymous but nonce-gated.
|
||||
|
||||
## 4. Schema — create the change-log table
|
||||
|
||||
`Version20260726155733` creates `jmap_change_log`; already migrated.
|
||||
|
||||
## 5. Doctrine — map the App\Jmap\State entities
|
||||
|
||||
`ChangeLog` lives outside `src/Entity`, which is the only directory the `App`
|
||||
mapping covers. `config/packages/doctrine.yaml`, under `orm.mappings`:
|
||||
|
||||
```yaml
|
||||
Jmap:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Jmap/State'
|
||||
prefix: 'App\Jmap\State'
|
||||
alias: Jmap
|
||||
```
|
||||
|
||||
## 6. Verified
|
||||
|
||||
`Core/echo` round-trips, `/jmap/session` lists all three connected accounts,
|
||||
and the negative paths return the right envelopes: 401 without a bearer token,
|
||||
`unknownCapability` for an unadvertised `using`, `notJSON` for a malformed body,
|
||||
and an inline `unknownMethod` error that does not abort later calls in the list.
|
||||
@@ -38,6 +38,18 @@
|
||||
"knplabs/knp-paginator-bundle": {
|
||||
"version": "v6.10.0"
|
||||
},
|
||||
"lexik/jwt-authentication-bundle": {
|
||||
"version": "3.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.5",
|
||||
"ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/lexik_jwt_authentication.yaml"
|
||||
]
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "13.2",
|
||||
"recipe": {
|
||||
|
||||
@@ -23,6 +23,8 @@ Variables: form, message, inline (bool), frame (string), thread (int|null)
|
||||
<turbo-frame id="{{ frame }}">
|
||||
<div data-controller="compose compose-toolbar"
|
||||
data-compose-inline-value="{{ inline ? 'true' : 'false' }}"
|
||||
data-compose-min-chars-value="{{ min_chars|default(5) }}"
|
||||
{% if thread is not null %}data-compose-thread-value="{{ thread }}"{% endif %}
|
||||
{% if null == message.id %}
|
||||
data-compose-draft-url-value="{{ path('app_compose_form_new', ctx) }}"
|
||||
data-compose-send-url-value="{{ path('app_compose_mail_send', ctx) }}"
|
||||
@@ -99,7 +101,7 @@ Variables: form, message, inline (bool), frame (string), thread (int|null)
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<button type="button" title="Save draft and close"
|
||||
<button type="button" title="{{ 'compose.save_and_close'|trans }}"
|
||||
data-action="compose#saveDraft compose#close"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg
|
||||
text-ink-muted hover:text-ink
|
||||
@@ -370,8 +372,8 @@ Variables: form, message, inline (bool), frame (string), thread (int|null)
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<button type="button"
|
||||
data-action="compose#close"
|
||||
title="Discard draft"
|
||||
data-action="compose#discard"
|
||||
title="{{ 'compose.discard'|trans }}"
|
||||
class="{{ ic }} hover:text-danger">
|
||||
<i class="fa-regular fa-trash-can"></i>
|
||||
</button>
|
||||
|
||||
@@ -16,33 +16,40 @@ Variables:
|
||||
{# A draft has no body worth reading — clicking it opens the editor in the
|
||||
reply zone, which then hides this row for as long as it is open. #}
|
||||
{% if isDraft %}
|
||||
{% set composeParams = message.thread is not null
|
||||
? { frame: 'compose_inline', thread: message.thread.id }
|
||||
{# The row is its own turbo-frame, so the editor opens right here rather
|
||||
than at the foot of the thread. compose#close navigates it back. #}
|
||||
{% set threaded = message.thread is not null %}
|
||||
{% set draftFrame = threaded ? 'compose_draft_' ~ message.id : 'compose_dock' %}
|
||||
{% set composeParams = threaded
|
||||
? { frame: draftFrame, thread: message.thread.id }
|
||||
: {} %}
|
||||
{% set snippet = message.bodyText ? message.bodyText|striptags|trim : '' %}
|
||||
|
||||
<div id="thread_message_{{ message.id }}" class="border-b border-line last:border-b-0 py-4 first:pt-0">
|
||||
<a
|
||||
href="{{ path('app_compose_edit', composeParams|merge({ id: message.id })) }}"
|
||||
data-turbo-frame="{{ message.thread is not null ? 'compose_inline' : 'compose_dock' }}"
|
||||
class="flex items-center gap-3 cursor-pointer group"
|
||||
>
|
||||
<div class="w-9 h-9 rounded-full bg-sunken border border-line flex-shrink-0 flex items-center justify-center text-ink-faint text-sm">
|
||||
<i class="fa-regular fa-pen-to-square text-xs" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-sm font-medium text-danger">{{ 'message.draft'|trans }}</span>
|
||||
<span class="text-xs text-ink-muted truncate">
|
||||
{{ message.bodyText ? message.bodyText|striptags|slice(0, 120) : ('compose.empty_draft'|trans) }}
|
||||
</span>
|
||||
<turbo-frame id="{{ draftFrame }}">
|
||||
<a
|
||||
href="{{ path('app_compose_edit', composeParams|merge({ id: message.id })) }}"
|
||||
data-turbo-frame="{{ draftFrame }}"
|
||||
class="flex items-center gap-3 cursor-pointer group"
|
||||
>
|
||||
<div class="w-9 h-9 rounded-full bg-sunken border border-line flex-shrink-0 flex items-center justify-center text-ink-faint text-sm">
|
||||
<i class="fa-regular fa-pen-to-square text-xs" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-ink-faint whitespace-nowrap group-hover:text-ink-soft transition-colors">
|
||||
{{ 'compose.continue_editing'|trans }}
|
||||
</span>
|
||||
</a>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-sm font-medium text-danger">{{ 'message.draft'|trans }}</span>
|
||||
<span class="text-xs text-ink-muted truncate">
|
||||
{{ snippet ?: (message.subject ?: ('compose.empty_draft'|trans)) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-ink-faint whitespace-nowrap group-hover:text-ink-soft transition-colors">
|
||||
{{ 'compose.continue_editing'|trans }}
|
||||
</span>
|
||||
</a>
|
||||
</turbo-frame>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
|
||||
@@ -213,6 +213,8 @@ compose:
|
||||
no_recipients: No recipients
|
||||
empty_draft: (empty draft)
|
||||
continue_editing: Continue editing
|
||||
save_and_close: Save draft and close
|
||||
discard: Delete draft
|
||||
|
||||
thread:
|
||||
no_subject: (no subject)
|
||||
|
||||
Reference in new issue
Block a user