mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
imap presets and test
This commit is contained in:
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
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user