Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ so anything that changes the schema irreversibly is called out explicitly.
The published image tags: `latest` follows the most recent release below,
`main` follows the tip of the default branch, and `sha-…` pins one commit.

## Unreleased

**plMail speaks French.** Choose **Français** under **Settings → General → Language**, or when
creating the first account. The whole interface is translated, including the admin panel, the
validation and sign-in messages and the emoji picker's search; French also defaults to the
24-hour clock, as German does. No migration.

## v0.3.5 — 2026-10-13

**Browsers and password managers kept offering your logins in client-secret fields.** Opening
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ provider doesn't.
with what changed since the one they run.
- **Yours** — two-factor auth, sign-in through Authentik, Keycloak or any other OpenID Connect
provider, per-app JMAP passwords, 41 themes, ten icons for the tab and the phone's home screen,
English, German and Pirate.
English, German, French and Pirate.

## A look around

Expand Down
1 change: 1 addition & 0 deletions assets/emoji/fr/data.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion config/packages/translation.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
framework:
default_locale: en
# Keep in sync with App\Domain\Enum\AppLocale and translations/messages.*.yaml.
enabled_locales: ['en', 'de', 'en_PI']
enabled_locales: ['en', 'de', 'fr', 'en_PI']
translator:
default_path: '%kernel.project_dir%/translations'
providers:
4 changes: 2 additions & 2 deletions docs/de/features/appearance.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- translated-from: features/appearance.md sha1:43f57642347736db71ec013bef9e22bfb70ee747 -->
<!-- translated-from: features/appearance.md sha1:963b5496c55b39c78ef14815a4c3c0e5b2ae8f78 -->

# Darstellung

Expand Down Expand Up @@ -281,7 +281,7 @@ wurde, bevor es ein bestimmtes Bedienelement gab, trotzdem irgendwo Sinnvollem l
wird. Deine Mail bleibt genau so, wie sie geschrieben wurde — es wird nichts übersetzt und
nichts umgeschrieben.

plMail liefert **English**, **Deutsch** und **Pirate English** mit. Eine Änderung lädt die
plMail liefert **English**, **Deutsch**, **Français** und **Pirate English** mit. Eine Änderung lädt die
Seite neu, statt sie nachzubessern, denn jede Zeichenkette auf dem Bildschirm muss neu
gerendert werden.

Expand Down
2 changes: 1 addition & 1 deletion docs/features/appearance.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ sensible.
**Settings → General → Language** sets the language the interface is shown in. Your mail is left
exactly as it was written — nothing is translated, and nothing is rewritten.

plMail ships **English**, **Deutsch** and **Pirate English**. Changing it reloads the page rather
plMail ships **English**, **Deutsch**, **Français** and **Pirate English**. Changing it reloads the page rather
than patching it, because every string on screen has to be re-rendered.

The same section carries **Time zone**, which decides the clock times and dates you are shown. It
Expand Down
3 changes: 3 additions & 0 deletions src/Domain/Enum/AppLocale.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ enum AppLocale: string
{
case English = 'en';
case German = 'de';
case French = 'fr';
case Pirate = 'en_PI';

/**
Expand All @@ -24,6 +25,7 @@ public function nativeLabel(): string
return match ($this) {
self::English => 'English',
self::German => 'Deutsch',
self::French => 'Français',
self::Pirate => 'Pirate English',
};
}
Expand All @@ -36,6 +38,7 @@ public function emoji(): string
return match ($this) {
self::English => '🇬🇧',
self::German => '🇩🇪',
self::French => '🇫🇷',
self::Pirate => '🏴‍☠️',
};
}
Expand Down
3 changes: 2 additions & 1 deletion src/Domain/Enum/User/ClockFormat.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ public function hour(): string
public static function forLocale(?AppLocale $locale): self
{
return match ($locale) {
AppLocale::German => self::TwentyFour,
AppLocale::German,
AppLocale::French => self::TwentyFour,
default => self::Twelve,
};
}
Expand Down
2 changes: 1 addition & 1 deletion templates/compose/_window.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,7 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
<div data-controller="ui--dropdown compose--emoji-picker"
data-action="ui--dropdown:opened->compose--emoji-picker#build"
data-compose--emoji-picker-locale-value="{{ app.request.locale|split('_')|first }}"
data-compose--emoji-picker-data-source-value="{{ asset('emoji/' ~ (app.request.locale starts with 'de' ? 'de' : 'en') ~ '/data.json') }}"
data-compose--emoji-picker-data-source-value="{{ asset('emoji/' ~ (app.request.locale|split('_')|first in ['de', 'fr'] ? app.request.locale|split('_')|first : 'en') ~ '/data.json') }}"
data-compose--emoji-picker-i18n-value="{{ {
categoriesLabel: 'compose.emoji.categories'|trans,
emojiUnsupportedMessage: 'compose.emoji.unsupported'|trans,
Expand Down
2 changes: 1 addition & 1 deletion tests/Domain/Ai/EmbeddingPresetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public function testEverySummaryIsTranslatedEverywhere(): void
$translator = self::getContainer()->get(TranslatorInterface::class);

foreach (EmbeddingPreset::ordered() as $preset) {
foreach (['en', 'de', 'en_PI'] as $locale) {
foreach (['en', 'de', 'fr', 'en_PI'] as $locale) {
$key = $preset->summaryKey();
$text = $translator->trans($key, [], 'messages', $locale);

Expand Down
3 changes: 2 additions & 1 deletion tests/Infrastructure/Translation/CatalogueParityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
use Symfony\Component\Yaml\Yaml;

/**
* Every key in the English catalogue exists in German and in pirate English.
* Every key in the English catalogue exists in German, French and pirate English.
*
* A missing key is not an error anywhere Symfony looks: the translator falls
* back to `en`, the page renders, and the only symptom is an English sentence
Expand All @@ -27,6 +27,7 @@ final class CatalogueParityTest extends TestCase
public static function locales(): iterable
{
yield 'de' => ['de'];
yield 'fr' => ['fr'];
yield 'en_PI' => ['en_PI'];
}

Expand Down
4 changes: 4 additions & 0 deletions tests/Service/User/ClockFormatResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,12 @@ public function testAUserWhoNeverChoseFollowsTheirLanguage(): void
$english = new User();
$english->locale = 'en';

$french = new User();
$french->locale = 'fr';

self::assertSame(ClockFormat::TwentyFour, $this->resolver()->resolve($german));
self::assertSame(ClockFormat::Twelve, $this->resolver()->resolve($english));
self::assertSame(ClockFormat::TwentyFour, $this->resolver()->resolve($french), 'French writes 14 h 00, never 2 PM');
}

/** A user with no locale of their own follows the install's. */
Expand Down
Loading
Loading