more thread ui and jmap start

This commit is contained in:
Paul Lützner committed 2026-07-26 18:06:50 +02:00
1 parent a1da945283
commit 4ef4616ee8
46 files changed
+2042 -61

No files matched your search

+7
View File
@@ -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 ###
+4
View File
@@ -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
View File
@@ -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">
+1
View File
@@ -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" />
+153 -26
View File
@@ -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;
}
+1
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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],
];
+6
View File
@@ -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)%'
+7
View File
@@ -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 }
+96
View File
@@ -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,
+6
View File
@@ -9,3 +9,9 @@
controllers:
resource: routing.controllers
jmap:
resource:
path: ../src/Jmap/Controller/
namespace: App\Jmap\Controller
type: attribute
+7
View File
@@ -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']
+32
View File
@@ -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');
}
}
+94 -3
View File
@@ -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);
+69
View File
@@ -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));
}
}
+26
View File
@@ -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;
}
}
+29
View File
@@ -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;
}
+37
View File
@@ -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);
}
}
+26
View File
@@ -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
{
}
+62
View File
@@ -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];
}
}
+68
View File
@@ -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;
}
}
+75
View File
@@ -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));
}
}
}
}
+56
View File
@@ -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);
}
}
+39
View File
@@ -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;
}
}
+145
View File
@@ -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);
}
}
+71
View File
@@ -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.
+127
View File
@@ -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,
];
}
}
+59
View File
@@ -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();
}
}
+104
View File
@@ -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();
}
}
+44
View File
@@ -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,
];
}
}
+12
View File
@@ -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';
}
+17
View File
@@ -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';
}
+190
View File
@@ -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);
}
}
+98
View File
@@ -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.
+12
View File
@@ -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": {
+5 -3
View File
@@ -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>
+29 -22
View File
@@ -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 %}
+2
View File
@@ -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)