imap presets and test

This commit is contained in:
Paul Lützner committed 2026-07-24 12:20:05 +02:00
1 parent d70db38d48
commit 63f0d0b238
21 files changed
+1581 -348

No files matched your search

@@ -0,0 +1,110 @@
import { Controller } from "@hotwired/stimulus";
/**
* Probes the currently entered IMAP/SMTP settings without saving them.
*
* Inputs are resolved by name suffix (account[imapHost] → imapHost) so the
* controller needs no per-field target attributes of its own.
*/
export default class extends Controller {
static targets = ["button", "spinner", "panel", "imapRow", "imapText", "smtpRow", "smtpText", 'imapTarget', 'smtpTarget'];
static values = {
url: String,
accountId: Number,
csrf: String,
};
async run() {
this._setBusy(true);
this.panelTarget.classList.add("hidden");
try {
const response = await fetch(this.urlValue, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": this.csrfValue,
},
body: JSON.stringify(this._payload()),
});
if (response.ok === false) {
this._render(
{ ok: false, message: `Request failed (${response.status}).` },
{ ok: false, message: "" },
);
return;
}
const result = await response.json();
this._render(result.imap, result.smtp);
} catch (error) {
this._render({ ok: false, message: error.message }, { ok: false, message: "" });
} finally {
this._setBusy(false);
}
}
// ── Private ───────────────────────────────────────────────────────────
_payload() {
const payload = {
username: this._value("username"),
password: this._value("password"),
imapHost: this._value("imapHost"),
imapPort: this._value("imapPort"),
imapEncryption: this._value("imapEncryption"),
smtpHost: this._value("smtpHost"),
smtpPort: this._value("smtpPort"),
smtpEncryption: this._value("smtpEncryption"),
};
if (this.hasAccountIdValue === true) {
payload.accountId = this.accountIdValue;
}
return payload;
}
_value(name) {
const field = this.element.querySelector(`[name$="[${name}]"]`);
if (field === null) {
return "";
}
return field.value.trim();
}
_setBusy(busy) {
this.buttonTarget.disabled = busy;
this.spinnerTarget.classList.toggle("hidden", busy === false);
}
_render(imap, smtp) {
this.panelTarget.classList.remove("hidden");
this.imapTargetTarget.textContent = imap.target ?? "";
this.smtpTargetTarget.textContent = smtp.target ?? "";
this._paint(this.imapRowTarget, this.imapTextTarget, imap);
this._paint(this.smtpRowTarget, this.smtpTextTarget, smtp);
}
_paint(row, text, probe) {
const icon = row.querySelector("i");
// ok === null means "not configured" — neither pass nor fail.
const isSkipped = probe.ok === null;
const isOk = probe.ok === true;
icon.className = isSkipped
? "fa-solid fa-circle-minus mt-0.5 shrink-0 text-ink-faint"
: isOk
? "fa-solid fa-circle-check mt-0.5 shrink-0 text-success"
: "fa-solid fa-circle-xmark mt-0.5 shrink-0 text-danger";
text.textContent = probe.message;
text.classList.toggle("text-ink-faint", isSkipped);
text.classList.toggle("text-ink-soft", isSkipped === false);
}
}
@@ -0,0 +1,151 @@
import { Controller } from "@hotwired/stimulus";
/**
* Prefills IMAP/SMTP settings from a shipped provider preset.
*
* Fields are resolved by name suffix (account[imapHost] → imapHost) rather
* than by Stimulus targets, so the controller does not depend on the form
* theme passing data attributes through to the widget.
*
* The preset table is embedded on the select element itself (data-presets) —
* the whole table is a couple of kilobytes, so no round-trip is needed.
*/
export default class extends Controller {
static targets = ["note"];
connect() {
this.presets = this._readPresets();
}
/** Explicit choice from the dropdown — always wins. */
apply() {
const select = this._field("preset");
if (select === null) {
return;
}
const preset = this.presets[select.value];
if (preset === undefined) {
this._renderNote(null);
return;
}
this._fill(preset);
}
/** Best-effort detection from the typed address. Never clobbers input. */
detect() {
const select = this._field("preset");
if (select === null || select.value !== "") {
return;
}
if (this._value("imapHost") !== "") {
return;
}
const address = this._value("username");
const at = address.lastIndexOf("@");
if (at === -1) {
return;
}
const domain = address.slice(at + 1).toLowerCase();
const key = Object.keys(this.presets).find(
(candidate) => this.presets[candidate].domains.includes(domain),
);
if (key === undefined) {
return;
}
this._select(select, key);
}
// ── Private ───────────────────────────────────────────────────────────
_readPresets() {
const select = this._field("preset");
if (select === null || select.dataset.presets === undefined) {
console.error("imap-preset: preset select not found or carries no payload");
return {};
}
try {
return JSON.parse(select.dataset.presets);
} catch (error) {
console.error("imap-preset: malformed preset payload", error);
return {};
}
}
_field(name) {
return this.element.querySelector(`[name$="[${name}]"]`);
}
_value(name) {
const field = this._field(name);
if (field === null) {
return "";
}
return field.value.trim();
}
/** Sets the dropdown, going through Tom Select when it is active. */
_select(select, key) {
if (select.tomselect !== undefined) {
select.tomselect.setValue(key, false);
return;
}
select.value = key;
select.dispatchEvent(new Event("change", { bubbles: true }));
}
_fill(preset) {
this._set("imapHost", preset.imap.host);
this._set("imapPort", preset.imap.port);
this._set("imapEncryption", preset.imap.encryption);
this._set("smtpHost", preset.smtp.host);
this._set("smtpPort", preset.smtp.port);
this._set("smtpEncryption", preset.smtp.encryption);
this._renderNote(preset.note);
}
_set(name, value) {
const field = this._field(name);
if (field === null) {
console.warn(`imap-preset: no field matching [name$="[${name}]"]`);
return;
}
field.value = value;
field.dispatchEvent(new Event("input", { bubbles: true }));
field.dispatchEvent(new Event("change", { bubbles: true }));
}
_renderNote(note) {
if (this.hasNoteTarget === false) {
return;
}
if (note === null || note === undefined || note === "") {
this.noteTarget.classList.add("hidden");
this.noteTarget.textContent = "";
return;
}
this.noteTarget.textContent = note;
this.noteTarget.classList.remove("hidden");
}
}
+141 -147
View File
@@ -1,8 +1,18 @@
/*
* tom-select.css
* Custom skin for Symfony UX Autocomplete / Tom Select used in the
* plMail compose window. All default Tom Select CSS bundles are disabled
* in controllers.json — this is the sole skin.
* Custom skin for Symfony UX Autocomplete / Tom Select.
* All default Tom Select CSS bundles are disabled in controllers.json —
* this is the sole skin.
*
* Two consumers with different needs, distinguished by Tom Select's own
* wrapper classes:
* .ts-wrapper.multi — compose To/Cc/Bcc chip fields, frameless, dropdown
* deliberately bleeds into the label gutter
* .ts-wrapper.single — standalone form selects (account provider preset),
* styled to match .form-select
*
* Colours resolve through the palette tokens in app.css so the skin follows
* the active theme rather than hardcoding blue.
*/
/* ── Base reset ──────────────────────────────────────────────────────── */
@@ -22,13 +32,37 @@
min-width: 0;
}
/* UX Autocomplete wraps the <select> in an extra div — flatten it */
.ts-wrapper.multi.has-items .ts-control,
.ts-wrapper.multi .ts-control {
padding-bottom: 2px;
/* Hide the original <select> that Tom Select moves off-screen */
.ts-hidden-accessible {
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* ── Control (the visible input area) ───────────────────────────────── */
/* Hide Tom Select's global clear button — chips carry their own removes */
.ts-wrapper .clear-button,
.ts-wrapper > .clear-button {
display: none !important;
}
/* Hide the loading spinner — UX Autocomplete handles loading state */
.ts-wrapper .spinner {
display: none !important;
}
/* Hide the dropdown caret Tom Select appends to the control */
.ts-wrapper .ts-control::after,
.ts-wrapper.single .ts-control::after,
.ts-wrapper.multi .ts-control::after {
display: none !important;
}
/* ── Control — shared base (frameless; .single re-frames below) ──────── */
.ts-control {
display: flex;
flex-wrap: wrap;
@@ -44,6 +78,11 @@
border-radius: 0;
}
.ts-wrapper.multi.has-items .ts-control,
.ts-wrapper.multi .ts-control {
padding-bottom: 2px;
}
/* The text input Tom Select injects */
.ts-control input {
flex: 1 1 80px;
@@ -56,37 +95,60 @@
margin: 0 !important;
font-size: 0.875rem;
line-height: 1.5;
color: rgb(31 41 55); /* gray-800 */
}
.dark .ts-control input {
color: rgb(243 244 246); /* gray-100 */
color: rgb(var(--rgb-ink));
}
.ts-control input::placeholder {
color: rgb(156 163 175); /* gray-400 */
color: rgb(var(--rgb-ink-faint));
}
.dark .ts-control input::placeholder {
color: rgb(107 114 128); /* gray-500 */
/* ── Control — single select, styled as a normal form field ─────────── */
.ts-wrapper.single .ts-control {
display: flex;
flex-wrap: nowrap;
align-items: center;
min-height: 2.25rem;
padding: 0 0.75rem !important;
background-color: rgb(var(--rgb-field) / var(--field-alpha)) !important;
border: 1px solid rgb(var(--rgb-field-border) / var(--field-border-alpha)) !important;
border-radius: 0.5rem;
cursor: pointer;
color: rgb(var(--rgb-ink));
}
/* Hide the dropdown caret Tom Select appends to the wrapper */
.ts-wrapper .ts-control::after,
.ts-wrapper.single .ts-control::after,
.ts-wrapper.multi .ts-control::after {
display: none !important;
.ts-wrapper.single .ts-control input {
cursor: pointer;
}
/* ── Address chips ───────────────────────────────────────────────────── */
.ts-wrapper.single.focus .ts-control,
.ts-wrapper.single.dropdown-active .ts-control {
border-color: rgb(var(--rgb-accent) / 0.6) !important;
box-shadow: 0 0 0 2px rgb(var(--rgb-accent) / 0.15) !important;
}
/* Multi stays frameless in every state */
.ts-wrapper.multi.focus .ts-control,
.ts-wrapper.multi.dropdown-active .ts-control {
outline: none !important;
box-shadow: none !important;
border: none !important;
}
/* ── Disabled ────────────────────────────────────────────────────────── */
.ts-wrapper.disabled .ts-control {
opacity: 0.5;
pointer-events: none;
}
/* ── Address chips (multi only) ──────────────────────────────────────── */
.ts-control .item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px 2px 9px;
border-radius: 9999px;
background-color: rgb(219 234 254); /* blue-100 */
color: rgb(29 78 216); /* blue-700 */
background-color: rgb(var(--rgb-accent) / 0.15);
color: rgb(var(--rgb-accent));
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.4;
@@ -98,12 +160,18 @@
user-select: none;
}
.dark .ts-control .item {
background-color: rgba(37 99 235 / 0.18);
color: rgb(147 197 253); /* blue-300 */
/* Single select shows a plain label, not a chip */
.ts-wrapper.single .ts-control .item {
background: transparent;
color: rgb(var(--rgb-ink));
padding: 0;
border-radius: 0;
font-size: 0.875rem;
font-weight: 400;
max-width: none;
}
/* × remove button — per chip */
/* × remove button — per chip (remove_button plugin) */
.ts-control .item .remove {
display: inline-flex;
align-items: center;
@@ -120,6 +188,7 @@
border: none;
background: transparent;
padding: 0;
margin-left: 2px;
flex-shrink: 0;
text-decoration: none;
transition: opacity 100ms;
@@ -129,36 +198,26 @@
opacity: 1;
}
/* Hide Tom Select's global clear button — we use per-chip removes */
.ts-wrapper .clear-button,
.ts-wrapper > .clear-button {
display: none !important;
}
/* ── Dropdown panel ──────────────────────────────────────────────────── */
.ts-dropdown {
position: absolute;
left: -4rem;
right: -1rem;
left: 0;
right: 0;
top: calc(100% + 4px);
z-index: 40;
border-radius: 0.75rem;
overflow: hidden;
background-color: rgba(255 255 255 / 0.96);
border: 1px solid rgba(209 213 219 / 0.7);
background-color: rgb(var(--rgb-surface) / 0.98);
border: 1px solid rgb(var(--rgb-line) / 0.15);
box-shadow:
0 10px 30px -5px rgba(0 0 0 / 0.12),
0 4px 12px -2px rgba(0 0 0 / 0.07);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
0 10px 30px -5px rgb(var(--rgb-shadow) / 0.25),
0 4px 12px -2px rgb(var(--rgb-shadow) / 0.15);
}
.dark .ts-dropdown {
background-color: rgba(17 24 39 / 0.96);
border-color: rgba(255 255 255 / 0.1);
box-shadow:
0 10px 30px -5px rgba(0 0 0 / 0.5),
0 4px 12px -2px rgba(0 0 0 / 0.3);
/* Compose address fields deliberately bleed into the label gutter */
.ts-wrapper.multi .ts-dropdown {
left: -4rem;
right: -1rem;
}
.ts-dropdown-content {
@@ -167,6 +226,22 @@
padding: 4px 0;
}
/* ── Optgroup headers ────────────────────────────────────────────────── */
.ts-dropdown .optgroup-header {
padding: 8px 14px 3px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgb(var(--rgb-ink-faint));
cursor: default;
}
.ts-dropdown .optgroup + .optgroup .optgroup-header {
margin-top: 4px;
border-top: 1px solid rgb(var(--rgb-line) / var(--line-alpha));
}
/* ── Options ─────────────────────────────────────────────────────────── */
.ts-dropdown .option {
display: flex;
@@ -175,23 +250,13 @@
padding: 7px 14px;
cursor: pointer;
transition: background-color 80ms;
color: rgb(55 65 81); /* gray-700 */
}
.dark .ts-dropdown .option {
color: rgb(229 231 235); /* gray-200 */
color: rgb(var(--rgb-ink-soft));
}
.ts-dropdown .option.active,
.ts-dropdown .option:hover {
background-color: rgb(239 246 255); /* blue-50 */
color: rgb(29 78 216); /* blue-700 */
}
.dark .ts-dropdown .option.active,
.dark .ts-dropdown .option:hover {
background-color: rgba(37 99 235 / 0.15);
color: rgb(147 197 253); /* blue-300 */
background-color: rgb(var(--rgb-accent) / 0.12);
color: rgb(var(--rgb-accent));
}
.ts-dropdown .option.selected {
@@ -199,7 +264,7 @@
opacity: 0.45;
}
/* ── Option inner layout ─────────────────────────────────────────────── */
/* ── Option inner layout (contact rows) ──────────────────────────────── */
.ts-option-row {
display: flex;
align-items: center;
@@ -214,8 +279,8 @@
width: 26px;
height: 26px;
border-radius: 9999px;
background: linear-gradient(135deg, rgb(59 130 246), rgb(99 102 241));
color: white;
background-color: rgb(var(--rgb-accent));
color: rgb(var(--rgb-accent-ink));
font-size: 10px;
font-weight: 600;
flex-shrink: 0;
@@ -254,11 +319,7 @@
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: rgb(107 114 128); /* gray-500 */
}
.dark .ts-option-create {
color: rgb(156 163 175); /* gray-400 */
color: rgb(var(--rgb-ink-muted));
}
.ts-option-create-icon {
@@ -268,110 +329,43 @@
width: 18px;
height: 18px;
border-radius: 9999px;
background-color: rgb(219 234 254); /* blue-100 */
color: rgb(37 99 235); /* blue-600 */
background-color: rgb(var(--rgb-accent) / 0.15);
color: rgb(var(--rgb-accent));
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.dark .ts-option-create-icon {
background-color: rgba(37 99 235 / 0.2);
color: rgb(147 197 253);
}
.ts-dropdown .create {
padding: 7px 14px;
cursor: pointer;
transition: background-color 80ms;
border-top: 1px solid rgba(209 213 219 / 0.5);
}
.dark .ts-dropdown .create {
border-top-color: rgba(255 255 255 / 0.08);
border-top: 1px solid rgb(var(--rgb-line) / var(--line-alpha));
}
.ts-dropdown .create:hover,
.ts-dropdown .create.active {
background-color: rgb(249 250 251);
}
.dark .ts-dropdown .create:hover,
.dark .ts-dropdown .create.active {
background-color: rgba(255 255 255 / 0.05);
background-color: rgb(var(--rgb-raised) / var(--hover-alpha));
}
/* ── No results ──────────────────────────────────────────────────────── */
.ts-no-results {
padding: 10px 14px;
font-size: 0.8125rem;
color: rgb(156 163 175); /* gray-400 */
color: rgb(var(--rgb-ink-faint));
text-align: center;
}
/* ── Spinner — hidden, UX Autocomplete handles loading state ─────────── */
.ts-wrapper .spinner {
display: none !important;
}
/* ── Focus / open state — keep frameless ─────────────────────────────── */
.ts-wrapper.focus .ts-control,
.ts-wrapper.dropdown-active .ts-control {
outline: none !important;
box-shadow: none !important;
border: none !important;
}
/* ── Disabled ────────────────────────────────────────────────────────── */
.ts-wrapper.disabled .ts-control {
opacity: 0.5;
pointer-events: none;
}
/* ── Thin scrollbar ──────────────────────────────────────────────────── */
.ts-dropdown-content::-webkit-scrollbar { width: 4px; }
.ts-dropdown-content::-webkit-scrollbar-track { background: transparent; }
.ts-dropdown-content::-webkit-scrollbar-thumb {
background-color: rgba(156 163 175 / 0.45);
border-radius: 9999px;
}
.dark .ts-dropdown-content::-webkit-scrollbar-thumb {
background-color: rgba(75 85 99 / 0.5);
}
/* Hide the original <select> that Tom Select moves off-screen */
.ts-hidden-accessible {
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
clip: rect(0,0,0,0) !important;
white-space: nowrap !important;
border: 0 !important;
.ts-dropdown-content::-webkit-scrollbar {
width: 4px;
}
/* remove_button plugin — × button appended to each chip by Tom Select */
.ts-control .item .remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 9999px;
font-size: 13px;
line-height: 1;
opacity: 0.5;
cursor: pointer;
color: inherit;
border: none;
.ts-dropdown-content::-webkit-scrollbar-track {
background: transparent;
padding: 0;
margin-left: 2px;
flex-shrink: 0;
text-decoration: none;
transition: opacity 100ms;
}
.ts-control .item .remove:hover {
opacity: 1;
.ts-dropdown-content::-webkit-scrollbar-thumb {
background-color: rgb(var(--rgb-ink-faint) / 0.45);
border-radius: 9999px;
}
+328
View File
@@ -0,0 +1,328 @@
# IMAP/SMTP presets shown in the "Provider" dropdown on the account form.
#
# encryption: ssl | starttls | none (must match the form choice values)
# domains: used to auto-detect a preset from the email address the user types
# note: optional hint rendered under the dropdown (translation-free, short)
presets:
# ── Deutschland / DACH ────────────────────────────────────────────────
gmx:
name: GMX
group: Deutschland
domains: ['gmx.de', 'gmx.net', 'gmx.at', 'gmx.ch']
note: 'IMAP muss im GMX-Webinterface unter Einstellungen erst aktiviert werden.'
imap: { host: imap.gmx.net, port: 993, encryption: ssl }
smtp: { host: mail.gmx.net, port: 587, encryption: starttls }
gmx-com:
name: GMX (International)
group: Deutschland
domains: ['gmx.com', 'gmx.us']
imap: { host: imap.gmx.com, port: 993, encryption: ssl }
smtp: { host: mail.gmx.com, port: 587, encryption: starttls }
web-de:
name: WEB.DE
group: Deutschland
domains: ['web.de']
note: 'IMAP muss im WEB.DE-Webinterface erst freigeschaltet werden.'
imap: { host: imap.web.de, port: 993, encryption: ssl }
smtp: { host: smtp.web.de, port: 587, encryption: starttls }
t-online:
name: Telekom / T-Online
group: Deutschland
domains: ['t-online.de', 'magenta.de']
note: 'Es wird ein separates E-Mail-Passwort benötigt, nicht das Telekom-Login.'
imap: { host: secureimap.t-online.de, port: 993, encryption: ssl }
smtp: { host: securesmtp.t-online.de, port: 587, encryption: starttls }
freenet:
name: freenet
group: Deutschland
domains: ['freenet.de']
imap: { host: mx.freenet.de, port: 993, encryption: ssl }
smtp: { host: mx.freenet.de, port: 587, encryption: starttls }
mailbox-org:
name: mailbox.org
group: Deutschland
domains: ['mailbox.org']
imap: { host: imap.mailbox.org, port: 993, encryption: ssl }
smtp: { host: smtp.mailbox.org, port: 465, encryption: ssl }
posteo:
name: Posteo
group: Deutschland
domains: ['posteo.de', 'posteo.net']
imap: { host: posteo.de, port: 993, encryption: ssl }
smtp: { host: posteo.de, port: 465, encryption: ssl }
mail-de:
name: mail.de
group: Deutschland
domains: ['mail.de']
imap: { host: imap.mail.de, port: 993, encryption: ssl }
smtp: { host: smtp.mail.de, port: 465, encryption: ssl }
vodafone-de:
name: Vodafone / Arcor
group: Deutschland
domains: ['vodafone.de', 'vodafonemail.de', 'arcor.de', 'kabelmail.de']
imap: { host: imap.vodafonemail.de, port: 993, encryption: ssl }
smtp: { host: smtp.vodafonemail.de, port: 587, encryption: starttls }
netcologne:
name: NetCologne
group: Deutschland
domains: ['netcologne.de']
imap: { host: imap.netcologne.de, port: 993, encryption: ssl }
smtp: { host: smtp.netcologne.de, port: 587, encryption: starttls }
ewe:
name: EWE
group: Deutschland
domains: ['ewe.net', 'ewetel.net']
imap: { host: imap.ewe.net, port: 993, encryption: ssl }
smtp: { host: smtp.ewe.net, port: 587, encryption: starttls }
bluewin:
name: Swisscom Bluewin
group: Deutschland
domains: ['bluewin.ch']
imap: { host: imap.bluewin.ch, port: 993, encryption: ssl }
smtp: { host: smtp.bluewin.ch, port: 465, encryption: ssl }
# ── Hosting-Provider ──────────────────────────────────────────────────
hetzner:
name: Hetzner (Konsole H)
group: Hosting
domains: []
note: 'Benutzername ist die vollständige E-Mail-Adresse.'
imap: { host: mail.your-server.de, port: 993, encryption: ssl }
smtp: { host: mail.your-server.de, port: 587, encryption: starttls }
ionos:
name: IONOS / 1&1
group: Hosting
domains: ['ionos.de', 'online.de']
imap: { host: imap.ionos.de, port: 993, encryption: ssl }
smtp: { host: smtp.ionos.de, port: 587, encryption: starttls }
strato:
name: STRATO
group: Hosting
domains: []
imap: { host: imap.strato.de, port: 993, encryption: ssl }
smtp: { host: smtp.strato.de, port: 465, encryption: ssl }
domainfactory:
name: domainFACTORY
group: Hosting
domains: []
note: 'Gemeinsamer Mailpool-Host; Benutzername ist die volle Adresse.'
imap: { host: sslmailpool.ispgateway.de, port: 993, encryption: ssl }
smtp: { host: sslmailpool.ispgateway.de, port: 465, encryption: ssl }
ovh:
name: OVH
group: Hosting
domains: []
imap: { host: ssl0.ovh.net, port: 993, encryption: ssl }
smtp: { host: ssl0.ovh.net, port: 465, encryption: ssl }
infomaniak:
name: Infomaniak
group: Hosting
domains: ['ik.me', 'etik.com']
imap: { host: mail.infomaniak.com, port: 993, encryption: ssl }
smtp: { host: mail.infomaniak.com, port: 465, encryption: ssl }
migadu:
name: Migadu
group: Hosting
domains: []
imap: { host: imap.migadu.com, port: 993, encryption: ssl }
smtp: { host: smtp.migadu.com, port: 465, encryption: ssl }
rackspace:
name: Rackspace Email
group: Hosting
domains: []
imap: { host: secure.emailsrvr.com, port: 993, encryption: ssl }
smtp: { host: secure.emailsrvr.com, port: 465, encryption: ssl }
# ── International ─────────────────────────────────────────────────────
gmail:
name: Gmail
group: International
domains: ['gmail.com', 'googlemail.com']
note: 'Requires an app password; prefer the OAuth tab instead.'
imap: { host: imap.gmail.com, port: 993, encryption: ssl }
smtp: { host: smtp.gmail.com, port: 587, encryption: starttls }
outlook:
name: Outlook.com / Hotmail
group: International
domains: ['outlook.com', 'outlook.de', 'hotmail.com', 'hotmail.de', 'live.com', 'live.de', 'msn.com']
note: 'Microsoft has disabled basic auth for most tenants — use the OAuth tab.'
imap: { host: outlook.office365.com, port: 993, encryption: ssl }
smtp: { host: smtp-mail.outlook.com, port: 587, encryption: starttls }
icloud:
name: iCloud Mail
group: International
domains: ['icloud.com', 'me.com', 'mac.com']
note: 'Requires an app-specific password.'
imap: { host: imap.mail.me.com, port: 993, encryption: ssl }
smtp: { host: smtp.mail.me.com, port: 587, encryption: starttls }
yahoo:
name: Yahoo Mail
group: International
domains: ['yahoo.com', 'yahoo.de', 'ymail.com', 'rocketmail.com']
note: 'Requires an app password.'
imap: { host: imap.mail.yahoo.com, port: 993, encryption: ssl }
smtp: { host: smtp.mail.yahoo.com, port: 465, encryption: ssl }
aol:
name: AOL Mail
group: International
domains: ['aol.com', 'aol.de']
imap: { host: imap.aol.com, port: 993, encryption: ssl }
smtp: { host: smtp.aol.com, port: 465, encryption: ssl }
fastmail:
name: Fastmail
group: International
domains: ['fastmail.com', 'fastmail.fm']
note: 'Requires an app password.'
imap: { host: imap.fastmail.com, port: 993, encryption: ssl }
smtp: { host: smtp.fastmail.com, port: 465, encryption: ssl }
protonmail-bridge:
name: Proton Mail (Bridge)
group: International
domains: ['proton.me', 'protonmail.com', 'pm.me']
note: 'Only works with Proton Mail Bridge running on the same host.'
imap: { host: 127.0.0.1, port: 1143, encryption: none }
smtp: { host: 127.0.0.1, port: 1025, encryption: starttls }
zoho-eu:
name: Zoho Mail (EU)
group: International
domains: ['zoho.eu']
imap: { host: imap.zoho.eu, port: 993, encryption: ssl }
smtp: { host: smtp.zoho.eu, port: 465, encryption: ssl }
zoho-com:
name: Zoho Mail (COM)
group: International
domains: ['zoho.com', 'zohomail.com']
imap: { host: imap.zoho.com, port: 993, encryption: ssl }
smtp: { host: smtp.zoho.com, port: 465, encryption: ssl }
mail-com:
name: mail.com
group: International
domains: ['mail.com', 'email.com', 'usa.com']
imap: { host: imap.mail.com, port: 993, encryption: ssl }
smtp: { host: smtp.mail.com, port: 587, encryption: starttls }
mailfence:
name: Mailfence
group: International
domains: ['mailfence.com']
imap: { host: imap.mailfence.com, port: 993, encryption: ssl }
smtp: { host: smtp.mailfence.com, port: 465, encryption: ssl }
runbox:
name: Runbox
group: International
domains: ['runbox.com']
imap: { host: imap.runbox.com, port: 993, encryption: ssl }
smtp: { host: smtp.runbox.com, port: 465, encryption: ssl }
purelymail:
name: Purelymail
group: International
domains: ['purelymail.com']
imap: { host: imap.purelymail.com, port: 993, encryption: ssl }
smtp: { host: smtp.purelymail.com, port: 465, encryption: ssl }
hushmail:
name: Hushmail
group: International
domains: ['hushmail.com']
imap: { host: imap.hushmail.com, port: 993, encryption: ssl }
smtp: { host: smtp.hushmail.com, port: 465, encryption: ssl }
yandex:
name: Yandex Mail
group: International
domains: ['yandex.com', 'yandex.ru', 'ya.ru']
imap: { host: imap.yandex.com, port: 993, encryption: ssl }
smtp: { host: smtp.yandex.com, port: 465, encryption: ssl }
mail-ru:
name: Mail.ru
group: International
domains: ['mail.ru', 'inbox.ru', 'bk.ru', 'list.ru']
imap: { host: imap.mail.ru, port: 993, encryption: ssl }
smtp: { host: smtp.mail.ru, port: 465, encryption: ssl }
seznam:
name: Seznam.cz
group: International
domains: ['seznam.cz', 'email.cz']
imap: { host: imap.seznam.cz, port: 993, encryption: ssl }
smtp: { host: smtp.seznam.cz, port: 465, encryption: ssl }
wp-pl:
name: WP.pl
group: International
domains: ['wp.pl']
imap: { host: imap.wp.pl, port: 993, encryption: ssl }
smtp: { host: smtp.wp.pl, port: 465, encryption: ssl }
orange-fr:
name: Orange
group: International
domains: ['orange.fr', 'wanadoo.fr']
imap: { host: imap.orange.fr, port: 993, encryption: ssl }
smtp: { host: smtp.orange.fr, port: 465, encryption: ssl }
free-fr:
name: Free
group: International
domains: ['free.fr']
imap: { host: imap.free.fr, port: 993, encryption: ssl }
smtp: { host: smtp.free.fr, port: 465, encryption: ssl }
libero:
name: Libero
group: International
domains: ['libero.it', 'inwind.it', 'iol.it']
imap: { host: imapmail.libero.it, port: 993, encryption: ssl }
smtp: { host: smtp.libero.it, port: 465, encryption: ssl }
comcast:
name: Comcast / Xfinity
group: International
domains: ['comcast.net']
imap: { host: imap.comcast.net, port: 993, encryption: ssl }
smtp: { host: smtp.comcast.net, port: 587, encryption: starttls }
qq:
name: QQ Mail
group: International
domains: ['qq.com', 'foxmail.com']
imap: { host: imap.qq.com, port: 993, encryption: ssl }
smtp: { host: smtp.qq.com, port: 465, encryption: ssl }
netease-163:
name: NetEase 163
group: International
domains: ['163.com', '126.com']
imap: { host: imap.163.com, port: 993, encryption: ssl }
smtp: { host: smtp.163.com, port: 465, encryption: ssl }
+2 -2
View File
@@ -1508,8 +1508,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }
* @psalm-type MercureConfig = array{
* hubs?: array<string, array{ // Default: []
* url?: scalar|Param|null, // URL of the hub's publish endpoint // Default: null
* public_url?: scalar|Param|null, // URL of the hub's public endpoint
* url?: scalar|Param|null, // URL of the hub's publish endpoint
* public_url?: scalar|Param|null, // URL of the hub's public endpoint // Default: null
* jwt?: string|array{ // JSON Web Token configuration.
* value?: scalar|Param|null, // JSON Web Token to use to publish to this hub.
* provider?: scalar|Param|null, // The ID of a service to call to provide the JSON Web Token.
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\AccountRepository;
use App\Service\Mail\ConnectionTester;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:mail:test-connection', description: 'Probe an account\'s IMAP/SMTP settings.')]
final class TestConnectionCommand extends Command
{
public function __construct(
private readonly AccountRepository $accountRepository,
private readonly ConnectionTester $tester,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('accountId', InputArgument::REQUIRED, 'Account id');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$account = $this->accountRepository->find((int) $input->getArgument('accountId'));
if (null === $account) {
$io->error('Account not found.');
return Command::FAILURE;
}
$result = $this->tester->test($account);
$io->definitionList(
['IMAP' => $result->imapTarget],
['' => $result->imapMessage],
['SMTP' => $result->smtpTarget],
[' ' => $result->smtpMessage],
);
return Command::SUCCESS;
}
}
+69 -2
View File
@@ -18,6 +18,11 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\UX\Turbo\TurboBundle;
use Throwable;
use App\Domain\DTO\ConnectionTestResult;
use App\Service\Mail\ConnectionTester;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
#[Route('/account', name: 'app_account_')]
#[IsGranted('ROLE_USER')]
@@ -34,7 +39,12 @@ final class AccountController extends AbstractController
#[Route('/new', name: 'new')]
public function new(Request $request): Response
{
$account = new Account();
$account = new Account()
->setImapPort(993)
->setImapEncryption('ssl')
->setSmtpPort(587)
->setSmtpEncryption('starttls');
$form = $this->createForm(AccountType::class, $account, ['action' => $this->generateUrl('app_account_new')]);
$form->handleRequest($request);
@@ -95,8 +105,8 @@ final class AccountController extends AbstractController
}
return $this->render('account/_edit_modal.html.twig', [
'account' => $account,
'form' => $form,
'account' => $account,
]);
}
@@ -184,6 +194,63 @@ final class AccountController extends AbstractController
], new Response(headers: ['Content-Type' => 'text/vnd.turbo-stream.html']));
}
#[Route('/test-connection', name: 'test_connection', methods: ['POST'])]
public function testConnection(Request $request, ConnectionTester $tester): JsonResponse
{
if (false === $this->isCsrfTokenValid('account_test', (string) $request->headers->get('X-CSRF-Token'))) {
throw new BadRequestHttpException('Invalid CSRF token.');
}
$payload = json_decode($request->getContent(), true);
if (false === is_array($payload)) {
throw new BadRequestHttpException('Malformed payload.');
}
$account = new Account()
->setAuthType('password')
->setUsername((string) ($payload['username'] ?? ''))
->setPassword((string) ($payload['password'] ?? ''))
->setImapHost((string) ($payload['imapHost'] ?? ''))
->setImapPort((int) ($payload['imapPort'] ?? 993))
->setImapEncryption((string) ($payload['imapEncryption'] ?? 'ssl'))
->setSmtpHost((string) ($payload['smtpHost'] ?? ''))
->setSmtpPort((int) ($payload['smtpPort'] ?? 587))
->setSmtpEncryption((string) ($payload['smtpEncryption'] ?? 'starttls'));
// Blank password on the edit form means "keep the stored one".
if ('' === $account->getPassword() && null !== ($payload['accountId'] ?? null)) {
$existing = $this->accountRepository->find((int) $payload['accountId']);
if (null === $existing) {
throw $this->createNotFoundException();
}
$this->denyUnlessOwner($existing);
$account->setPassword($existing->getPassword());
}
if ('' === $account->getUsername() || '' === $account->getImapHost()) {
return $this->json(new ConnectionTestResult(
false,
'Enter at least an email address and an IMAP host first.',
null,
'',
)->toArray());
}
if ('' === $account->getPassword()) {
return $this->json(new ConnectionTestResult(
false,
'No password available to test with. Enter one above — on the edit form a blank field means "keep the stored password", which the tester can only resolve once the account id reaches it.',
null,
'',
)->toArray());
}
return $this->json($tester->test($account)->toArray());
}
/**
* Writes sequential sortOrder and re-derives the single primary (first row).
* Caller is responsible for flushing.
+7 -2
View File
@@ -193,8 +193,7 @@ class ComposeController extends AbstractController
public function send(Request $request, ?Message $message): Response
{
if (null === $message) {
$message = new Message()
->setAccount($this->defaultAccount());
$message = new Message();
} else {
$this->assertOwnership($message);
}
@@ -285,6 +284,10 @@ class ComposeController extends AbstractController
'isPrimary' => true,
]);
if (null !== $account && $account->getUsr() !== $this->getUser()) {
throw $this->createAccessDeniedException();
}
if (null !== $account) {
return $account;
}
@@ -305,6 +308,8 @@ class ComposeController extends AbstractController
*/
private function applyAccount(Message $message, Account $account): void
{
$message->setAccount($account);
$draftsLabel = $this->labelResolver->systemLabel(LabelRole::Drafts, $account);
foreach ($message->getLabels() as $label) {
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Domain\DTO;
/**
* Outcome of an account connection probe.
*
* smtpOk is nullable: null means "no SMTP host configured", which is a valid
* receive-only account rather than a failure.
*/
final class ConnectionTestResult
{
public function __construct(
public readonly bool $imapOk,
public readonly string $imapMessage,
public readonly string $imapTarget,
public readonly ?bool $smtpOk,
public readonly string $smtpMessage,
public readonly string $smtpTarget,
) {
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'imap' => ['ok' => $this->imapOk, 'message' => $this->imapMessage, 'target' => $this->imapTarget],
'smtp' => ['ok' => $this->smtpOk, 'message' => $this->smtpMessage, 'target' => $this->smtpTarget],
];
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Domain\DTO;
/**
* A single IMAP/SMTP provider preset, loaded from config/mail_presets.yaml.
*
* Immutable reference data — never persisted, never user-editable.
*/
final class MailPreset
{
/**
* @param string[] $domains
*/
public function __construct(
public readonly string $key,
public readonly string $name,
public readonly string $group,
public readonly array $domains,
public readonly string $imapHost,
public readonly int $imapPort,
public readonly string $imapEncryption,
public readonly string $smtpHost,
public readonly int $smtpPort,
public readonly string $smtpEncryption,
public readonly ?string $note = null,
) {
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(string $key, array $data): self
{
return new self(
key: $key,
name: (string) $data['name'],
group: (string) ($data['group'] ?? 'Other'),
domains: array_map(strtolower(...), $data['domains'] ?? []),
imapHost: (string) $data['imap']['host'],
imapPort: (int) $data['imap']['port'],
imapEncryption: (string) $data['imap']['encryption'],
smtpHost: (string) $data['smtp']['host'],
smtpPort: (int) $data['smtp']['port'],
smtpEncryption: (string) $data['smtp']['encryption'],
note: $data['note'] ?? null,
);
}
public function matchesDomain(string $domain): bool
{
return in_array(strtolower($domain), $this->domains, true);
}
/**
* Shape consumed by the imap-preset Stimulus controller.
*
* @return array<string, mixed>
*/
public function toClientArray(): array
{
return [
'name' => $this->name,
'note' => $this->note,
'imap' => [
'host' => $this->imapHost,
'port' => $this->imapPort,
'encryption' => $this->imapEncryption,
],
'smtp' => [
'host' => $this->smtpHost,
'port' => $this->smtpPort,
'encryption' => $this->smtpEncryption,
],
'domains' => $this->domains,
];
}
}
+5 -1
View File
@@ -22,7 +22,7 @@ class ImapConnectionFactory
) {
}
public function connect(Account $account): Client
public function connect(Account $account, ?int $timeout = null): Client
{
$encryption = match ($account->getImapEncryption()) {
'ssl' => 'ssl',
@@ -48,6 +48,10 @@ class ImapConnectionFactory
$accountConfig['authentication'] = null;
}
if (null !== $timeout) {
$accountConfig['timeout'] = $timeout;
}
$client = new Client(Config::make([
'default' => 'default',
'accounts' => [
+36 -14
View File
@@ -3,6 +3,7 @@
namespace App\Form;
use App\Entity\Account;
use App\Service\Mail\MailPresetProvider;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
@@ -15,9 +16,29 @@ use Symfony\Component\Validator\Constraints\Range;
class AccountType extends AbstractType
{
public function __construct(
private readonly MailPresetProvider $presetProvider,
)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('preset', ChoiceType::class, [
'mapped' => false,
'required' => false,
'label' => 'account.form.preset.label',
'placeholder' => 'account.form.preset.placeholder',
'choices' => $this->presetProvider->choices(),
'autocomplete' => true,
'attr' => [
'class' => 'form-select',
'data-imap-preset-target' => 'select',
'data-action' => 'change->imap-preset#apply',
'data-presets' => json_encode($this->presetProvider->toClientArray(), JSON_THROW_ON_ERROR),
],
])
->add('email', TextType::class, [
'attr' => [
'placeholder' => 'you@example.com',
@@ -27,11 +48,13 @@ class AccountType extends AbstractType
])
->add('username', TextType::class, [
'attr' => [
'placeholder' => 'you@example.com',
'class' => 'form-input',
'autocomplete' => 'email',
'placeholder' => 'you@example.com',
'class' => 'form-input',
'autocomplete' => 'email',
'data-imap-preset-target' => 'username',
'data-action' => 'change->imap-preset#detect blur->imap-preset#detect',
],
'label' => 'Email address',
'label' => 'Email address',
'constraints' => [new NotBlank()],
])
->add('password', PasswordType::class, [
@@ -58,14 +81,13 @@ class AccountType extends AbstractType
'class' => 'form-input',
],
'label' => 'IMAP port',
'data' => 993,
'constraints' => [new NotBlank(), new Range(min: 1, max: 65535)],
])
->add('imapEncryption', ChoiceType::class, [
'choices' => [
'SSL / TLS' => 'ssl',
'STARTTLS' => 'starttls',
'None' => 'none',
'STARTTLS' => 'starttls',
'None' => 'none',
],
'attr' => ['class' => 'form-select'],
'label' => 'Encryption',
@@ -85,26 +107,26 @@ class AccountType extends AbstractType
'class' => 'form-input',
],
'label' => 'SMTP port',
'data' => 587,
'constraints' => [new Range(min: 1, max: 65535)],
])
->add('smtpEncryption', ChoiceType::class, [
'required' => false,
'choices' => [
'STARTTLS' => 'starttls',
'STARTTLS' => 'starttls',
'SSL / TLS' => 'ssl',
'None' => 'none',
'None' => 'none',
],
'attr' => ['class' => 'form-select'],
'attr' => [
'class' => 'form-select',
],
'label' => 'SMTP encryption',
])
;
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Account::class,
'data_class' => Account::class,
'require_password' => true,
]);
+189
View File
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\DTO\ConnectionTestResult;
use App\Domain\Helper\ImapConnectionFactory;
use App\Entity\Account;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mailer\Transport\Smtp\SmtpTransport;
use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream;
use Throwable;
/**
* Probes an account's IMAP and SMTP settings without persisting anything.
*
* The account passed in may be a transient entity built from unsaved form
* input — nothing here touches the entity manager.
*/
final class ConnectionTester
{
private const int TIMEOUT_SECONDS = 10;
public function __construct(
private readonly ImapConnectionFactory $imapFactory,
private readonly SmtpDsnFactory $dsnFactory,
) {
}
public function test(Account $account): ConnectionTestResult
{
[$imapOk, $imapMessage] = $this->probeImap($account);
[$smtpOk, $smtpMessage] = $this->probeSmtp($account);
return new ConnectionTestResult(
$imapOk,
$imapMessage,
$this->target($account->getImapHost(), $account->getImapPort(), $account->getImapEncryption()),
$smtpOk,
$smtpMessage,
$this->target($account->getSmtpHost(), $account->getSmtpPort(), $account->getSmtpEncryption()),
);
}
/**
* Human-readable echo of the settings actually used, so a failure never
* requires guessing which values reached the probe.
*/
private function target(?string $host, ?int $port, ?string $encryption): string
{
if (null === $host || '' === trim($host)) {
return '—';
}
return sprintf('%s:%d (%s)', $host, (int) $port, $encryption ?? 'unset');
}
/**
* @return array{0: bool, 1: string}
*/
private function probeImap(Account $account): array
{
$client = null;
try {
$client = $this->imapFactory->connect($account, self::TIMEOUT_SECONDS);
$folders = $client->getFolders(false);
return [true, sprintf('Connected — %d folders visible.', count($folders))];
} catch (Throwable $e) {
return [false, $this->describe($e, $account, $account->getImapEncryption(), $account->getImapPort())];
} finally {
if (null !== $client) {
try {
$client->disconnect();
} catch (Throwable) {
// Nothing useful to do — the probe result already stands.
}
}
}
}
/**
* @return array{0: bool|null, 1: string}
*/
private function probeSmtp(Account $account): array
{
$host = $account->getSmtpHost();
if (null === $host || '' === trim($host)) {
return [null, 'No SMTP host configured — sending is disabled for this account.'];
}
$transport = null;
try {
$transport = Transport::fromDsn($this->dsnFactory->forAccount($account));
if (false === $transport instanceof SmtpTransport) {
return [false, 'Resolved transport is not SMTP.'];
}
$stream = $transport->getStream();
if (true === $stream instanceof SocketStream) {
$stream->setTimeout(self::TIMEOUT_SECONDS);
}
// start() performs the full connect + EHLO + TLS + AUTH handshake,
// which is exactly what we want to verify.
$transport->start();
return [true, 'Connected and authenticated.'];
} catch (Throwable $e) {
return [false, $this->describe($e, $account, $account->getSmtpEncryption(), $account->getSmtpPort())];
} finally {
if (true === $transport instanceof SmtpTransport) {
try {
$transport->stop();
} catch (Throwable) {
// Ditto.
}
}
}
}
/**
* Webklex wraps the real cause in a generic ConnectionFailedException, and
* Symfony's mailer nests transport exceptions the same way — so the useful
* detail is always one or two levels down the chain.
*/
private function describe(Throwable $e, Account $account, ?string $encryption, ?int $port): string
{
$parts = [];
$current = $e;
$depth = 0;
while (null !== $current && $depth < 4) {
$message = trim($current->getMessage());
if ('' !== $message && false === in_array($message, $parts, true)) {
$parts[] = $message;
}
$current = $current->getPrevious();
$depth++;
}
if (count($parts) === 0) {
$parts[] = $e::class;
}
$hint = $this->portHint($encryption, $port);
if (null !== $hint) {
$parts[] = $hint;
}
return $this->dsnFactory->redact(implode(' — ', $parts), $account);
}
/**
* Appended only on failure: a plausible explanation rather than a hard
* block, since unusual-but-valid port/encryption pairings do exist.
*/
private function portHint(?string $encryption, ?int $port): ?string
{
if (null === $encryption || null === $port) {
return null;
}
$normalised = strtolower($encryption);
if ('ssl' === $normalised && in_array($port, [587, 143], true)) {
return sprintf('Hint: port %d normally expects STARTTLS, not implicit SSL/TLS.', $port);
}
if ('ssl' !== $normalised && in_array($port, [465, 993], true)) {
return sprintf('Hint: port %d normally expects implicit SSL/TLS.', $port);
}
if ('none' === $normalised) {
return 'Hint: encryption is set to None — most providers require SSL/TLS or STARTTLS.';
}
return null;
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\DTO\MailPreset;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Yaml\Yaml;
/**
* Reads the shipped provider presets. Deliberately file-backed rather than
* persisted: presets are release artefacts, so they upgrade with the code and
* need no migration or seeding on existing installations.
*/
final class MailPresetProvider
{
/** @var array<string, MailPreset>|null */
private ?array $presets = null;
public function __construct(
#[Autowire('%kernel.project_dir%/config/mail_presets.yaml')]
private readonly string $presetFile,
) {
}
/**
* @return array<string, MailPreset>
*/
public function all(): array
{
if (null !== $this->presets) {
return $this->presets;
}
if (false === is_file($this->presetFile)) {
$this->presets = [];
return $this->presets;
}
$parsed = Yaml::parseFile($this->presetFile);
$rows = $parsed['presets'] ?? [];
$result = [];
foreach ($rows as $key => $data) {
$result[(string) $key] = MailPreset::fromArray((string) $key, $data);
}
uasort($result, static fn (MailPreset $a, MailPreset $b): int => strcasecmp($a->name, $b->name));
$this->presets = $result;
return $this->presets;
}
public function get(string $key): ?MailPreset
{
return $this->all()[$key] ?? null;
}
public function findByEmail(string $email): ?MailPreset
{
$at = strrpos($email, '@');
if (false === $at) {
return null;
}
return $this->findByDomain(substr($email, $at + 1));
}
public function findByDomain(string $domain): ?MailPreset
{
foreach ($this->all() as $preset) {
if (true === $preset->matchesDomain($domain)) {
return $preset;
}
}
return null;
}
/**
* Grouped choices for ChoiceType — Tom Select renders these as optgroups.
*
* @return array<string, array<string, string>>
*/
public function choices(): array
{
$choices = [];
foreach ($this->all() as $preset) {
$choices[$preset->group][$preset->name] = $preset->key;
}
ksort($choices);
return $choices;
}
/**
* @return array<string, array<string, mixed>>
*/
public function toClientArray(): array
{
$payload = [];
foreach ($this->all() as $preset) {
$payload[$preset->key] = $preset->toClientArray();
}
return $payload;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Entity\Account;
/**
* Single source of truth for the SMTP DSN.
*
* Extracted from SmtpMailSender so the connection tester probes the exact
* transport configuration that a real send would use — a test that builds its
* own DSN can go green while sending still fails.
*/
final class SmtpDsnFactory
{
public function forAccount(Account $account): string
{
$rawEncryption = $account->getSmtpEncryption();
if (null === $rawEncryption) {
$encryption = 'tls';
} else {
$encryption = strtolower($rawEncryption);
}
if ('ssl' === $encryption) {
$scheme = 'smtps';
} else {
$scheme = 'smtp';
}
$port = $account->getSmtpPort();
if (null === $port) {
$port = 587;
}
return sprintf(
'%s://%s:%s@%s:%d',
$scheme,
urlencode($account->getUsername()),
urlencode($account->getPassword()),
$account->getSmtpHost(),
$port,
);
}
/**
* Strips the credentials out of any string that may embed the DSN —
* transport exceptions frequently echo it back verbatim.
*/
public function redact(string $text, Account $account): string
{
$password = (string) $account->getPassword();
if ('' === $password) {
return $text;
}
return str_replace(
[$password, urlencode($password)],
'***',
$text,
);
}
}
+6 -30
View File
@@ -18,6 +18,11 @@ use Symfony\Component\Mime\Email;
*/
class SmtpMailSender implements MailSenderInterface
{
public function __construct(
private readonly SmtpDsnFactory $dsnFactory,
) {
}
public function supports(Account $account): bool
{
if (AuthType::OAuth2->value === $account->getAuthType()) {
@@ -34,37 +39,8 @@ class SmtpMailSender implements MailSenderInterface
public function send(Email $email, Account $account): bool
{
$rawEncryption = $account->getSmtpEncryption();
if (null === $rawEncryption) {
$encryption = 'tls';
} else {
$encryption = strtolower($rawEncryption);
}
if ('ssl' === $encryption) {
$scheme = 'smtps';
} else {
$scheme = 'smtp';
}
$port = $account->getSmtpPort();
if (null === $port) {
$port = 587;
}
$dsn = sprintf(
'%s://%s:%s@%s:%d',
$scheme,
urlencode($account->getUsername()),
urlencode($account->getPassword()),
$account->getSmtpHost(),
$port,
);
try {
$mailer = new Mailer(Transport::fromDsn($dsn));
$mailer = new Mailer(Transport::fromDsn($this->dsnFactory->forAccount($account)));
$mailer->send($email);
} catch (TransportExceptionInterface $e) {
return false;
+1 -1
View File
@@ -29,7 +29,7 @@ Variables:
<div data-label-menu-target="panel"
class="hidden absolute right-0 top-full mt-1 z-30 w-56 max-h-72 overflow-y-auto rounded-xl
bg-pane-soft backdrop-blur-xl
bg-pane-soft backdrop-blur-sm
border border-line
shadow-pop py-1">
+11 -33
View File
@@ -4,41 +4,19 @@
<turbo-frame id="modal">
<template data-modal-frame-title>{{ 'settings.accounts.edit'|trans }}</template>
{{ form_start(form, { attr: { 'data-turbo': 'true', class: 'flex flex-col min-h-0' } }) }}
{{ form_start(form, {
attr: {
'data-turbo': 'true',
'data-controller': 'imap-preset connection-test',
'data-connection-test-url-value': path('app_account_test_connection'),
'data-connection-test-csrf-value': csrf_token('account_test'),
'data-connection-test-account-id-value': account.id,
class: 'flex flex-col min-h-0',
}
}) }}
<div class="overflow-y-auto px-5 py-5 space-y-5">
{% if form.vars.errors|length %}
<div role="alert" class="flex items-start gap-2.5 rounded-xl bg-red-500/10 border border-red-500/25 px-3.5 py-3 text-sm text-danger">
<i class="fa-solid fa-triangle-exclamation mt-0.5 shrink-0" aria-hidden="true"></i>
<div>{{ form_errors(form) }}</div>
</div>
{% endif %}
{{ form_row(form.email, { label: 'account.form.email'|trans }) }}
<div class="space-y-3">
<p class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">{{ 'account.form.imap.heading'|trans }}</p>
{{ form_row(form.username, { label: 'account.form.username'|trans }) }}
{{ form_row(form.password, { label: 'account.form.password'|trans, help: 'settings.accounts.password_help'|trans, attr: { placeholder: '••••••••' } }) }}
<div class="grid grid-cols-[1fr_100px] gap-3">
{{ form_row(form.imapHost, { label: 'account.form.imap.host'|trans }) }}
{{ form_row(form.imapPort, { label: 'account.form.port'|trans }) }}
</div>
{{ form_row(form.imapEncryption, { label: 'account.form.encryption'|trans }) }}
</div>
<div class="border-t border-line"></div>
<div class="space-y-3">
<p class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">{{ 'account.form.smtp.heading'|trans }}</p>
<div class="grid grid-cols-[1fr_100px] gap-3">
{{ form_row(form.smtpHost, { label: 'account.form.smtp.host'|trans }) }}
{{ form_row(form.smtpPort, { label: 'account.form.port'|trans }) }}
</div>
{{ form_row(form.smtpEncryption, { label: 'account.form.encryption'|trans }) }}
</div>
{% include 'account/_fields.html.twig' with { form: form, mode: 'edit' } %}
</div>
<div class="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-line bg-sunken">
+130
View File
@@ -0,0 +1,130 @@
{#
_fields.html.twig
─────────────────────────────────────────────────────────────────────────────
Shared IMAP/SMTP field block for the account create and edit modals.
The two modals differ only in their shell: creation offers an IMAP/OAuth tab
bar, editing is password-only (AccountController::edit() rejects OAuth
accounts outright). Everything between the preset dropdown and the connection
tester is identical, so it lives here.
Variables:
form FormView an AccountType form view
mode string "new" | "edit" — controls password help text only
Note: rows deliberately pass no `attr` override. The data-imap-preset-target
attributes are defined in AccountType, and a template-level `attr` replaces
that hash wholesale rather than merging into it. Where an override is
genuinely needed, merge onto form.<field>.vars.attr.
#}
{% set mode = mode|default('new') %}
{# ── Global errors ────────────────────────────────────────────────────── #}
{% if form.vars.errors|length %}
<div role="alert" class="flex items-start gap-2.5 rounded-xl
alert-danger
px-3.5 py-3 text-sm text-danger">
<i class="fa-solid fa-circle-exclamation mt-0.5 shrink-0 text-danger opacity-80" aria-hidden="true"></i>
<div class="space-y-0.5">
{% for error in form.vars.errors %}<p class="leading-snug">{{ error.message }}</p>{% endfor %}
</div>
</div>
{% endif %}
{# ── Provider preset ──────────────────────────────────────────────────── #}
<div class="space-y-2">
{{ form_row(form.preset, { label: 'account.form.preset.label'|trans }) }}
<p data-imap-preset-target="note"
class="hidden rounded-lg bg-accent-soft px-3 py-2 text-xs leading-snug text-accent"></p>
</div>
<div class="border-t border-line"></div>
{# ── Account basics ───────────────────────────────────────────────────── #}
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
{{ form_row(form.email, { label: 'account.form.email'|trans }) }}
{{ form_row(form.username, { label: 'account.form.username'|trans }) }}
</div>
{{ form_row(form.password, {
label: 'account.form.password'|trans,
help: mode == 'edit' ? 'settings.accounts.password_help'|trans : null,
attr: form.password.vars.attr|merge({ placeholder: '••••••••' })
}) }}
</div>
<div class="border-t border-line"></div>
{# ── Incoming — IMAP ──────────────────────────────────────────────────── #}
<div class="space-y-3">
<p class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
{{ 'account.form.imap.heading'|trans }}
</p>
<div class="grid grid-cols-[1fr_100px] gap-3">
{{ form_row(form.imapHost, { label: 'account.form.imap.host'|trans }) }}
{{ form_row(form.imapPort, { label: 'account.form.port'|trans }) }}
</div>
{{ form_row(form.imapEncryption, { label: 'account.form.encryption'|trans }) }}
</div>
<div class="border-t border-line"></div>
{# ── Outgoing — SMTP ──────────────────────────────────────────────────── #}
<div class="space-y-3">
<p class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
{{ 'account.form.smtp.heading'|trans }}
</p>
<div class="grid grid-cols-[1fr_100px] gap-3">
{{ form_row(form.smtpHost, { label: 'account.form.smtp.host'|trans }) }}
{{ form_row(form.smtpPort, { label: 'account.form.port'|trans }) }}
</div>
{{ form_row(form.smtpEncryption, { label: 'account.form.encryption'|trans }) }}
</div>
<div class="border-t border-line"></div>
{# ── Connection test ──────────────────────────────────────────────────── #}
<div class="space-y-3">
<div class="flex items-center gap-3">
<button
type="button"
data-connection-test-target="button"
data-action="click->connection-test#run"
class="inline-flex items-center gap-2 px-3 h-9 rounded-lg text-sm font-medium
border border-line text-ink-soft
hover:bg-hover transition-colors cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fa-solid fa-plug text-xs" aria-hidden="true"></i>
{{ 'account.test.button'|trans }}
</button>
<span data-connection-test-target="spinner" class="hidden">
<i class="fa-solid fa-circle-notch fa-spin text-ink-faint text-sm" aria-hidden="true"></i>
</span>
</div>
<div data-connection-test-target="panel"
class="hidden space-y-2 rounded-xl border border-line bg-sunken px-3.5 py-3"
aria-live="polite">
<div data-connection-test-target="imapRow" class="flex items-start gap-2.5 text-xs">
<i class="fa-solid fa-circle-notch mt-0.5 shrink-0 text-ink-faint" aria-hidden="true"></i>
<div class="min-w-0">
<p class="font-semibold text-ink">{{ 'account.test.imap'|trans }}</p>
<p data-connection-test-target="imapTarget" class="text-ink-faint font-mono text-[11px]"></p>
<p data-connection-test-target="imapText" class="text-ink-soft break-words leading-snug"></p>
</div>
</div>
<div data-connection-test-target="smtpRow" class="flex items-start gap-2.5 text-xs">
<i class="fa-solid fa-circle-notch mt-0.5 shrink-0 text-ink-faint" aria-hidden="true"></i>
<div class="min-w-0">
<p class="font-semibold text-ink">{{ 'account.test.smtp'|trans }}</p>
<p data-connection-test-target="smtpTarget" class="text-ink-faint font-mono text-[11px]"></p>
<p data-connection-test-target="smtpText" class="text-ink-soft break-words leading-snug"></p>
</div>
</div>
</div>
</div>
+37 -116
View File
@@ -7,12 +7,14 @@
{{ form_start(form, {
attr: {
'data-turbo': 'true',
'data-controller': 'account-form',
'data-controller': 'account-form imap-preset connection-test',
'data-connection-test-url-value': path('app_account_test_connection'),
'data-connection-test-csrf-value': csrf_token('account_test'),
class: 'flex flex-col min-h-0',
}
}) }}
{# ── Tab bar ─────────────────────────────────────────────────────────── #}
{# ── Tab bar ─────────────────────────────────────────────────────── #}
<div class="flex border-b border-line px-5 -mt-px bg-sunken">
<button
type="button"
@@ -30,69 +32,17 @@
>{{ 'account.tabs.oauth'|trans }}</button>
</div>
{# ── Scrollable body ──────────────────────────────────────────────────── #}
{# ── Scrollable body ─────────────────────────────────────────────── #}
<div class="overflow-y-auto">
{# Global errors #}
{% if form.vars.errors|length %}
<div role="alert" class="mx-5 mt-5 flex items-start gap-2.5 rounded-xl
alert-danger
px-3.5 py-3 text-sm text-danger">
<i class="fa-solid fa-circle-exclamation mt-0.5 shrink-0 text-danger opacity-80" aria-hidden="true"></i>
<div class="space-y-0.5">
{% for error in form.vars.errors %}<p class="leading-snug">{{ error.message }}</p>{% endfor %}
</div>
</div>
{% endif %}
{# ── IMAP / SMTP panel ──────────────────────────────────────────────── #}
{# IMAP / SMTP panel #}
<div data-account-form-target="panel" data-panel="imap" class="px-5 py-5 space-y-5">
{% include 'account/_fields.html.twig' with { form: form, mode: 'new' } %}
</div>
{# Account basics #}
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
{{ form_row(form.email, { label: 'account.form.email'|trans, attr: { placeholder: 'you@example.com' } }) }}
{{ form_row(form.username, { label: 'account.form.username'|trans, attr: { placeholder: 'you@example.com' } }) }}
</div>
{{ form_row(form.password, { label: 'account.form.password'|trans, attr: { placeholder: '••••••••' } }) }}
</div>
{# Divider #}
<div class="border-t border-line"></div>
{# IMAP #}
<div class="space-y-3">
<p class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
{{ 'account.form.imap.heading'|trans }}
</p>
<div class="grid grid-cols-[1fr_100px] gap-3">
{{ form_row(form.imapHost, { label: 'account.form.imap.host'|trans, attr: { placeholder: 'imap.example.com' } }) }}
{{ form_row(form.imapPort, { label: 'account.form.port'|trans, attr: { placeholder: '993' } }) }}
</div>
{{ form_row(form.imapEncryption, { label: 'account.form.encryption'|trans }) }}
</div>
{# Divider #}
<div class="border-t border-line"></div>
{# SMTP #}
<div class="space-y-3">
<p class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
{{ 'account.form.smtp.heading'|trans }}
</p>
<div class="grid grid-cols-[1fr_100px] gap-3">
{{ form_row(form.smtpHost, { label: 'account.form.smtp.host'|trans, attr: { placeholder: 'smtp.example.com' } }) }}
{{ form_row(form.smtpPort, { label: 'account.form.port'|trans, attr: { placeholder: '587' } }) }}
</div>
{{ form_row(form.smtpEncryption, { label: 'account.form.encryption'|trans }) }}
</div>
</div>{# /imap panel #}
{# ── OAuth panel ────────────────────────────────────────────────────── #}
{# OAuth panel #}
<div data-account-form-target="panel" data-panel="oauth" class="hidden px-5 py-5 space-y-4">
{# Info banner #}
<div class="flex items-start gap-3 rounded-xl
alert-info
px-4 py-3 text-sm text-accent">
@@ -100,80 +50,51 @@
<p>{{ 'account.oauth.info'|trans }}</p>
</div>
{# Provider buttons #}
<div class="space-y-2.5">
<a
href="{{ path('app_oauth_connect', { provider: 'google' }) }}"
data-turbo="false"
class="flex items-center gap-3 mb-3 w-full px-4 py-2.5 rounded-xl
border border-line
bg-sunken
hover:bg-hover
text-sm font-medium text-ink
transition-colors duration-100"
>
<svg viewBox="0 0 24 24" class="size-4 shrink-0" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
border border-line
text-sm font-medium text-ink-soft
hover:bg-hover transition-colors"
>
<i class="fa-brands fa-google shrink-0" aria-hidden="true"></i>
{{ 'account.oauth.google'|trans }}
</a>
<a
href="#"
href="{{ path('app_oauth_connect', { provider: 'microsoft' }) }}"
data-turbo="false"
class="flex items-center gap-3 w-full px-4 py-2.5 rounded-xl
border border-line
bg-sunken
hover:bg-hover
text-sm font-medium text-ink
transition-colors duration-100"
>
<svg viewBox="0 0 24 24" class="size-4 shrink-0" aria-hidden="true">
<path fill="#F25022" d="M1 1h10v10H1z"/>
<path fill="#00A4EF" d="M13 1h10v10H13z"/>
<path fill="#7FBA00" d="M1 13h10v10H1z"/>
<path fill="#FFB900" d="M13 13h10v10H13z"/>
</svg>
border border-line
text-sm font-medium text-ink-soft
hover:bg-hover transition-colors"
>
<i class="fa-brands fa-microsoft shrink-0" aria-hidden="true"></i>
{{ 'account.oauth.microsoft'|trans }}
</a>
</div>
<p class="text-[12px] text-ink-faint">{{ 'account.oauth.wip'|trans }}</p>
<p class="text-xs text-ink-faint">{{ 'account.oauth.wip'|trans }}</p>
</div>{# /oauth panel #}
</div>
</div>{# /scrollable body #}
{# ── Footer ───────────────────────────────────────────────────────────── #}
<div class="flex items-center justify-end gap-2 px-5 py-3.5
border-t border-line
bg-sunken
rounded-b-xl shrink-0">
<button
type="button"
data-action="click->modal#close"
class="inline-flex items-center justify-center h-8 px-3.5 rounded-lg text-sm font-medium
text-ink-soft
bg-sunken
border border-line
hover:bg-hover
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1
transition-colors duration-100 cursor-pointer"
>{{ 'form.cancel'|trans }}</button>
<button
type="submit"
data-account-form-target="submitBtn"
class="inline-flex items-center justify-center h-8 px-3.5 rounded-lg text-sm font-medium text-white
bg-blue-600 hover:bg-blue-500 active:bg-blue-700
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1
transition-colors duration-100 cursor-pointer"
>{{ 'account.add_title'|trans }}</button>
</div>
{# ── Footer ──────────────────────────────────────────────────────── #}
<div class="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-line bg-sunken">
<button type="button" data-action="click->modal#close"
class="px-3 h-9 rounded-lg text-sm font-medium text-ink-soft hover:bg-hover transition-colors cursor-pointer">
{{ 'form.cancel'|trans }}
</button>
<button type="submit"
data-account-form-target="submitBtn"
class="px-4 h-9 rounded-lg text-sm font-medium bg-accent hover:bg-accent-strong text-white transition-colors cursor-pointer">
{{ 'form.submit'|trans }}
</button>
</div>
{{ form_end(form) }}
</turbo-frame>
{% endblock %}
+7
View File
@@ -185,6 +185,10 @@ account:
tabs:
imap: IMAP / SMTP
oauth: OAuth
test:
button: Test connection
imap: IMAP
smtp: SMTP
form:
email: Email address
username: Username
@@ -197,6 +201,9 @@ account:
smtp:
heading: Outgoing — SMTP
host: Host
preset:
label: Provider
placeholder: Choose a provider…
oauth:
info: Sign in with your provider — no password stored.
google: Continue with Google