mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
main
14
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
13303b247e |
v0.3.9 milestone (#283)
* Run the end-to-end suite on pull requests into milestone and wip branches
A release's work is now collected on a milestone branch (milestone/v0.4.0)
before it reaches main, and the suite only ran on pull requests into main,
so nothing proposed for the milestone was tested. `milestone/**` joins
`main`, and `wip/**` comes along from wip/28, where it already runs the
suite on the phases of #28 — the same block on both, so they merge
without a conflict.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit
|
||
|
|
50c842cc9d |
Reconnecting an integration edits it, and a taken name is a form error (#217)
The setup wizard built a new connection every time its Integrations step was saved, so saving it again collided with the unique key on (user, provider, name) and answered with a 500. It now edits the oldest existing connection for that service. The settings page had the same 500 for a second connection that kept the default name; a UniqueEntity constraint now reports it on the name field. Closes #216. Co-authored-by: Philippe Delodder <philippe@delodder.be> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b453b79eb4 |
Single sign-on only: hide the password form and refuse password sign-ins (#210)
* Single sign-on only: hide the password form and refuse password sign-ins Adds Admin -> SSO 'Single sign-on only' (and OIDC_DISABLE_PASSWORD_LOGIN). The login page drops the password form and LoginFormAuthenticator refuses a password sign-in before looking the user up. It only applies while a whole provider is in force, the saving administrator must already be linked to the provider, and app:sso:allow-password-login brings the form back from the console. Config restore leaves it off. Off by default; one boolean column on oidc_config. Closes #169 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Single sign-on only: require a link to the provider being saved The admin form let the box be ticked by an administrator linked to any provider, including the one the page held before. Switching providers and ticking it in one save made a provider they had never used the only way in. It now compares the stored issuer with the one on the page, without a trailing slash, as OidcDiscovery does. Tests for what had none: the console command (clears the setting, keeps the provider), a restore over a row that had it on, the link to another provider, and a control showing the same password signs in with the setting off. The "right credentials" test now posts the right password. Docs: there is no iOS app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Docs: put the iOS app back in the single sign-on page The previous commit dropped it on the wrong assumption that there is no iOS app. There is one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Paul Lützner <karatektus@gmail.com> |
||
|
|
09c503c61c |
Sign in through an OpenID Connect provider (Authentik, Keycloak…) (#106)
plMail could only sign people in with a local password. On an install that already runs Authentik or Keycloak, that is one more account and password per person, outside the provider's access policy. A second authenticator on the `main` firewall, beside the password form. Both end in the same token for the same User, so the user checker, the login throttle and TOTP two-factor apply unchanged. Authorization code flow with PKCE, state and nonce on league/oauth2-client's GenericProvider; endpoints come from the provider's discovery document. - Set up under a new Admin → SSO tab; the OIDC_* variables are the fallback for an install configured from its compose file, and a saved provider wins whole. This lets an administrator who points it at a provider of their own sign in as any account without a second factor; the security model says so. - The ID token's signature is not verified: it only ever comes from the token endpoint over TLS (OIDC Core § 3.1.3.7 step 6). So the issuer and endpoints must be https and no request to the provider follows a redirect. Accepting an ID token from anywhere else needs JWKS verification first. - An account is linked by (issuer, sub). The address is used for the first link only, and only when the provider reports it verified. - Auto-creation is off by default and creates an ordinary account with no usable password. - app:user:oidc-unlink forgets a link from the console, for somebody deleted and re-added at the provider. Migration: two nullable columns and a unique index on `user`, and a one-row table `oidc_config`. Nothing backfilled; reversible. Not run: Playwright on this branch. No automated test makes a round trip against a real provider; that was checked by hand on a local test stack. Closes #91 Co-Authored-By: Paul Lützner <mail@pluetzner.de> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
cd7828395d |
Send the model host extra headers
A bearer token is not all that stands in front of a model. A reverse proxy with basic auth, a zero-trust gateway that wants a client id and a secret, a routing gateway with a key of its own, a provider that takes a project by header: each of those made plMail unusable with it. Admin → AI has an Extra headers box under the token, one `Name: value` per line. ConnectionHeaders reads them strictly while somebody can still fix what they typed, and forgivingly on the way to the wire, so a column that cannot be read costs the headers and not the feature. They are stored encrypted and ride on AiConnection, so both clients send them on every request without knowing they exist. The values are never shown again; the page lists the names on file. An empty box keeps them, typing replaces them, a tick box removes them, and a change of address or provider drops them as it drops the token. A typed Authorization replaces the bearer. The headers the HTTP client writes itself are refused. Fixed values only. The idea of extra headers comes from #58; its per-task session header is left out, because nothing plMail does needs one. Co-authored-by: shady <shady2k@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2361c14e41 |
fix(security): validate mail hosts and stop echoing server replies from the connection test
The SMTP host went into the mailer DSN unencoded, so a host such as `mail.example.com?verify_peer=0` added transport options; the credentials used urlencode against a rawurldecode parser. Hosts are now checked to be a hostname or IP (form constraint, connection test, DSN and IMAP factories), credentials are rawurlencoded and IPv6 is bracketed. The connection test returned the whole exception chain, i.e. the remote server's banner, to anyone who could type a host; it now answers with a translated category (auth, unreachable, TLS, timeout, invalid host) and logs the redacted chain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
961611a587 |
fix(i18n): two form errors rendered as raw keys; drop an unused key
The AI base URL and avatar type messages are constraint messages, which Symfony translates in the validators domain; they lived in messages, so a failed check showed the key. calendar.soon.now went unused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d8f8acf29b |
feat(ai): tunable keep-alive per model, and a warm-up button
The first "Help me write" after a quiet spell shows nothing for tens of seconds while the Ollama host reads twenty gigabytes back off disk. How long it waits before evicting a model is OLLAMA_KEEP_ALIVE — an environment variable on a machine plMail can talk to but cannot configure. The API takes the same setting per request and a per-request value wins, so this is the only lever available from inside the app. Two nullable columns on ai_settings, one per model, because the two are opposite trades: the writing model is eighteen gigabytes and asked for occasionally, the embedding model a few hundred megabytes and asked for on every search and every indexed message. Both ship at 5m, which is Ollama's own default, so an upgrade changes nothing about how a host behaves — the boxes just say out loud what was already happening. -1 is recommended for the search model in the help text and the handbook rather than shipped, because plMail cannot see how much memory that host has. Empty means send no keep_alive at all and let the host decide, which is a real selectable state and not an unfinished one. The wire representation is the part that is easy to get wrong: Ollama parses a JSON string as a Go duration, and a Go duration needs a unit, so "-1" and "3600" as strings are a 400 on every model call from a setting that reads as correct. KeepAlive::forBody() is the only thing that puts the value in a body — int for the numeric spellings, string for the durations, null to omit the field entirely, since an explicit null is read as zero and unloads immediately. One helper adds it to all four bodies so no call site can get that wrong. Validated with the same expression the body is built from, refused rather than coerced, and the message lives in the validators domain where Symfony actually looks for it. Warm up now: POST /admin/ai/warmup issues the no-prompt /api/generate, times the round trip and reports it. PHP's 30s execution limit is raised for it — a cold load is not 30 seconds — and the upstream timeout sits below that so it gives up first. A host that is unreachable and a model that is not on the host are distinct, translated answers. Also carries both columns through the configuration backup, and adds the AI feature to the README, which had never mentioned it. Fixes a search test that asserted how fast the machine was: it gave the last-resort body pass a one-millisecond budget and one row to scan, so on quick hardware the pass finished in time and the test failed. It now provokes a genuine statement_timeout cancellation with pg_sleep, which exercises the same guard without a clock in the assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
755f3cf257 |
feat(users): an administrator can run the whole life of an account, and you can change your own password
Managing users was reachable and worked — list, search, create, promote, demote, remove — but three things around it did not, and one of them was load-bearing. ── You could not change your own password ────────────────────────────────── Nowhere. Not in Settings, not by mail, not at all; only an administrator setting one for you. Settings → Security now leads with a Password card that asks for the current password (an unmapped UserPassword constraint, so it is checked against the signed-in token rather than against anything the form was handed) and takes the new one twice. The route carries no id and the form has no data_class, so it structurally cannot become the admin form by accident. The floor those passwords are held to was three literal 12s in three files that happened to agree. It is now User::PASSWORD_MIN_LENGTH, read by all three. ── An account could only be removed, never switched off ──────────────────── Nothing sat between "leave it alone" and "destroy it", so the answer to "this person has left, keep their mail" was to delete them. There is a deactivated_at now, and a UserChecker registered on BOTH firewalls — the JMAP one included, because an app password outlives the browser session it was minted from and a check only on `main` would shut somebody out of the web UI while every mail client they had already connected kept syncing. The refusal is in checkPostAuth rather than checkPreAuth on purpose. checkPreAuth runs before the password is verified, so refusing there would answer "is this address a suspended account here?" to anyone who types an address — the question hide_user_not_found exists to keep unanswerable. After verification, the only person told is the one who could otherwise have signed in. Symfony runs user checkers at authentication only, so a session already open would have carried on until it happened to end. UserProvider::refreshUser() refuses the same two states on every request. Suspension also travels through config backup, which would otherwise have handed a suspended account its sign-in back on restore. ── Two smaller lies ──────────────────────────────────────────────────────── A search under three characters silently returned the unfiltered list, which a QueryBuilder has no way to complain about, so the floor was dropped rather than kept as something that could only mislead. And an admin declining to demote themselves got a success toast for a change that never happened; the refusal now says so, while the rest of the form still saves. ── Restoring a removed user is deliberately absent ───────────────────────── Removal overwrites the address, the name and the hash specifically to free the unique address for reuse, so a restore would have to invent all three and the original address may by then belong to somebody else. Doing it properly means snapshot columns and a guided flow that asks for a fresh address — a different change, which alters what removal means. Documented as absent on the screen an administrator would look for the button on, because switching an account off is the answer to the question people are actually asking. Also fixed en route: the modal form theme dropped help_translation_parameters, so the first field to use one rendered "at least %count% characters" on screen. And GermanIsInformalTest only ever globbed messages.de.yaml, leaving validators and the new security catalogue — exactly where a translator reaches for "Sie" — outside the test that exists to catch it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
af5439fe3f |
Stop Enter sending mail, and make undo bring the window back
Six items from an external bug report against v0.0.30, all in compose. Enter in the To field sent the message. The mechanism is implicit form submission: Tom Select calls preventDefault on Enter only when it has a use for the key — a suggestion to commit, or a createItem() that succeeds. Type something with no "@" and neither applies, so the keydown reached the <form>, whose action is the send URL. The mail went out to nobody, with no subject and no body. Every existing test missed it because they all type valid addresses, which Tom Select always swallows. Enter is now claimed unconditionally and then asked what it should have meant: a chip in an address row, a move to the body elsewhere, and a refusal that says so when the text cannot be an address. Ctrl/Cmd+Enter is the one deliberate send. Undo was a 500. The undo response rendered the compose window without pickerIntegrations, and the cancel flag was flushed before that render — so the send was called off, the toast faded, and the draft was filed with no way back to it. It now renders as a turbo-stream with every variable the window needs and replaces the frame, so cancelling reopens what was being written, with recipients, subject and body intact. Recipients are validated at the chip, at the send, and on the server: a Count in the form's existing `send` group (so a draft may still have none), plus an address-format check on what is about to go on the wire. An empty subject or body asks first, Gmail-style. The From default and the PRIMARY badge named different accounts. isPrimary is positional on the user's own arrangement (AccountCreator::resequence), but the settings page listed alphabetically and badged loop.first — so the badge was a statement about the alphabet. The badge reads the flag now, the list is ordered the way the reorder endpoint already re-renders it, and compose's fallback for installs with no flag at all is ordered rather than arbitrary. The window was English inside a German UI. Every string is translated in all three catalogues, including the ones the Stimulus controller writes, which travel as an i18n value the way ui--select's already do. Accessible names: the recipient comboboxes, the typeface and size pickers, and the six unnamed icon buttons. The text-colour "A" was a label wrapping a 0x0, pointer-events:none, tabindex="-1" input — a picture of a button, in the accessibility tree not at all; the input now covers the control and is focusable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4e92d148e2 |
Mirror the calendars people already keep somewhere else
Google, Microsoft and CalDAV, subscribed from settings and synced both ways. One CalendarSyncDriverInterface behind three providers; the engine has never seen HTTP, OAuth or a VEVENT and must not learn to. The decision that shapes the rest: Google and Microsoft calendars ride the MAIL grant. todo.md said to give them their own Provider cases with their own grants, and that note is overruled here on purpose — a second OAuth app is a second thing to register, consent to and keep working, and the requirement was that enabling calendars cost an administrator one more permission checkbox and nothing else. Only CalDAV is its own Provider case, because only CalDAV is its own server. Google's consent screen can still refuse the scope by itself, so that surfaces where the calendars would have been rather than being tracked at grant time. Seven conflict rules, written into CalendarSyncService's docblock rather than discovered later. Two decide whether "changes sync back" is a promise or a hope: Push before pull, always. Pull first and every local edit collides with its own echo. A row changed on both sides loses its local copy LOUDLY — the whole discarded object logged before the overwrite. An edit that vanishes without a trace is the one outcome nobody can debug afterwards. Recurrence was the weak spot and is fixed in the same pass, because shipping it broken would have been shipping a wrong calendar. RRULE to JSCalendar existed in one driver's private copy while two other paths stashed the rule unconverted, and the expander reads only the converted form — so a weekly meeting that arrived by mail, or lived on a CalDAV server, drew exactly one occurrence. One conversion now serves all three, and a rule that cannot be converted faithfully is still refused whole: an event on the wrong days is worse than one that does not repeat. An instance moved out of its series had nowhere to live, and all three drivers hit it independently — a duplicate on the new day from Google, the old time from Microsoft. recurrenceOverrides was already read by the materialiser and produced by nothing. Now produced by all three, and CalDAV writes it back, because a PUT replaces the resource and reading overrides without writing them would delete every moved instance at the server the next time somebody renamed the series. Dates in ordinary prose are proposed, never filed. A guess is an EventProposal and not a CalendarEvent, and that is the whole design: the writer materialises occurrences, every range query reads occurrences, and a guess living in calendar_event leaks into a view the first time somebody forgets to exclude it. A proposal materialises nothing and therefore cannot. Accepting converts it through the one writer; dismissing writes the suppression row that already exists. The rule nobody would have predicted came from running the parser over a real mailbox: 56 of 57 gated messages yielded a date and every one was a reply's own furniture — "On Thu, Jul 2 … wrote:", "Sent: Friday …". The horizon rule refused them all, but only because a quoted message is older than the one quoting it, which is an accident. Quoted text is skipped now. Date:/Datum: is not, because German business mail states appointments that way. Two bugs fell out of asking whether a claim actually held. EventSource::isTrusted() was `return self::Llm !== $this`, so every case written after it silently inherited *trusted* — the exact permission the method exists to withhold, and invisible in a way a default arm at least is not. And an event accepted from a proposal could be rewritten by a later message: the guard covered extracted events only, and what really kept mail off these was that plMail mints UIDs no sender collides with. Hand the reconciler the event's own UID and it overwrote it. Both closed on the enum, exhaustively. Groundwork that made the above possible is in the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8f79ce370e |
Let an invitation be answered where it arrives
Invites were parsed, filed on a calendar and then unanswerable. The
participants were read out of the .ics and written into the event, and
nothing rendered them: an invitation was a thing that appeared on the
calendar, and the only way to say yes to it was another client.
A message carrying one now shows a card above it — when, where, who
called it, who else is coming and how they answered. Yes / Maybe / No
records the answer and sends the organiser an iTIP REPLY: same UID, same
SEQUENCE, one ATTENDEE line, method=REPLY on the part. That last one is
the whole point of the part and Symfony has no argument for it, so it is
pre-declared on the part's headers, where TextPart's prepared headers set
the Content-Type body without replacing parameters already there.
The card says what the answer was; the toast says whether it reached the
organiser. Those fail independently and only one of them is visible, so
a reply that could not be sent is worth interrupting somebody over — an
organiser who never heard "no" is still holding a seat.
Two more places where the model had run ahead of the UI:
EventSuppression existed from the first pass and nothing had ever
written a row, so deleting an extracted event
lasted until the next backfill re-created it from
the same message. "Not an event" records the
refusal against the claim's dedup key — every key,
applied or superseded, since the superseded one is
what a re-run would apply next.
Calendar carried a name, a colour, a zone, a visibility flag
and a default flag that nothing could reach, so a
user with four accounts had four calendars named
after usernames with no way to say where a new
event should land. Settings → Calendars does all of
it. A provisioned calendar offers no delete (it
would only be made again) and the default offers
neither delete nor hide (new events would vanish on
save).
Four bugs fell out.
The first is why the card looked broken on every real invitation: the
organiser is listed twice — ORGANIZER, and again as an ATTENDEE, because
they are going too — and participants were keyed by address and written
in property order, so the second line landed on top of the first and
took the `owner` role with it. No participant carried owner, so the
event had no organiser, so there was nobody to reply to. Roles
accumulate onto the address now. Existing rows need `app:backfill
events`.
The second is its mirror: re-reading an invitation un-answered it. The
mail that asked the question says NEEDS-ACTION about you forever, so
every re-extraction reset an RSVP that had already been sent. An answer
already recorded is kept unless the incoming copy states a real one.
The third is that editing an event dropped its participants — the
canonical object is rebuilt from the columns and the editor has no
participants field, so correcting a title silently un-answered the
invitation. Preserved the way recurrenceOverrides already was.
The fourth is not calendar at all. Closing a modal cleared where the
frame pointed but left the previous form in it, so the next open showed
the last dialog until its own fetch landed — pre-filled, focusable and
completely convincing — and anything typed in that window was discarded
when the real form arrived. It cost an hour of chasing a "save that did
nothing" before it turned out to be the dialog underneath. The frame
goes back to its spinner on close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
487fd34bf5 | restructure | ||
|
|
122006460d | onboarding. this is a lot |