mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-11 22:38:54 +00:00
7be81d3586
The daily refresh doubles as the health check for a connected OAuth mailbox, and its own docblock says why that matters: a grant can die silently, "which for a portal whose password-reset mails ride on this connection must surface as a warning, not as a support ticket weeks later". It decided whether to warn by reading last_error -- but the send path writes that column too. OAuthCodeFlowBroker::refresh() records the failure and notifies nobody, and freshAccessToken() reaches it from every send. So on an installation that actually sends mail, the send lands first, the command reads the column as "already told them", and the warning never goes out. last_error is cleared only by a successful refresh, which a dead grant never has, so it never goes out again either. Measured on main, one dead grant, two orders: nobody sends, command first 1 notification, then quiet correct a password-reset mail first 0 ... 0 ... 0 never The alarm worked on installations that were not using the mailbox and failed on the ones that were. The anti-nag rule is not the problem and does not change. The problem is that last_error answers "is this broken", which any writer may set, while the command needs "have the admins been told", which only the notifier can. The table's own comment shows the conflation -- one column described as "what the settings page's warning and the admin notification read". So the notification gets its own column. broken_notified_at is stamped when the command notifies, and cleared wherever last_error is cleared: a successful refresh, a disconnect, a changed client id. The three call sites go through MailOAuthConnection::clearFailure() rather than nulling two columns each, because a connection left marked "already told them" while healthy would go quiet the next time it died -- the same bug in a new place.
281 lines
13 KiB
PHP
281 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Platform\Http\Controllers;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Platform\Capabilities\Capability;
|
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
|
use App\Modules\Platform\Mail\MailOAuthConnection;
|
|
use App\Modules\Platform\Notifications\TestEmailNotification;
|
|
use App\Modules\Platform\Settings\MailConfigApplier;
|
|
use App\Modules\Platform\Settings\MailProvider;
|
|
use App\Modules\Platform\Settings\MailProviderSettings;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Validation\Rule;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
use Throwable;
|
|
|
|
/**
|
|
* The master switch for transactional email, the SMTP transport itself
|
|
* (a generic form with provider presets — see MailProvider), and the
|
|
* admin notification recipient list.
|
|
*
|
|
* Transport (provider/host/port/username/password/encryption) is
|
|
* community-edition only (Capability::EmailTransportConfigure) — cloud
|
|
* operates its own relay. Sender identity (from_address/from_name) and
|
|
* the notification toggle/recipients stay editable in both editions.
|
|
*/
|
|
class EmailSettingsController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly Settings $settings,
|
|
private readonly ActivityLogger $activity,
|
|
private readonly MailConfigApplier $mailConfig,
|
|
private readonly CapabilityRegistry $capabilities,
|
|
) {}
|
|
|
|
public function edit(Request $request): Response
|
|
{
|
|
$adminEmails = $this->settings->get(Setting::AdminNotificationEmails);
|
|
$mailProvider = MailProviderSettings::current();
|
|
|
|
return Inertia::render('system/settings/email', [
|
|
'email_notifications_enabled' => $this->settings->get(Setting::EmailNotificationsEnabled),
|
|
'admin_notification_emails' => is_array($adminEmails) ? $adminEmails : [],
|
|
'mail_provider' => [
|
|
'provider' => $mailProvider->provider->value,
|
|
'host' => $mailProvider->host ?? '',
|
|
'port' => $mailProvider->port,
|
|
'username' => $mailProvider->username ?? '',
|
|
'has_password' => $mailProvider->password !== null && $mailProvider->password !== '',
|
|
'encryption' => $mailProvider->encryption,
|
|
'from_address' => $mailProvider->from_address ?? '',
|
|
'from_name' => $mailProvider->from_name ?? '',
|
|
],
|
|
'mail_provider_presets' => array_map(fn (MailProvider $provider): array => [
|
|
'value' => $provider->value,
|
|
'label' => $provider->label(),
|
|
'host' => $provider->defaultHost(),
|
|
'port' => $provider->defaultPort(),
|
|
'oauth' => $provider->isOAuth(),
|
|
'needs_tenant' => $provider->needsTenant(),
|
|
], MailProvider::cases()),
|
|
// Keyed by provider so the form can switch providers without
|
|
// a round-trip; tokens and the secret never leave the server
|
|
// — only "is one stored" and the connection's health.
|
|
'mail_oauth_connections' => collect(MailProvider::cases())
|
|
->filter(fn (MailProvider $provider): bool => $provider->isOAuth())
|
|
->mapWithKeys(function (MailProvider $provider): array {
|
|
$connection = MailOAuthConnection::for($provider);
|
|
|
|
return [$provider->value => [
|
|
'client_id' => $connection->client_id ?? '',
|
|
'has_client_secret' => $connection->client_secret !== null && $connection->client_secret !== '',
|
|
'tenant_id' => $connection->tenant_id ?? '',
|
|
'connected' => $connection->usable(),
|
|
'account_email' => $connection->account_email,
|
|
'last_refreshed_at' => $connection->last_refreshed_at?->toIso8601String(),
|
|
'last_error' => $connection->last_error,
|
|
]];
|
|
}),
|
|
'test_result' => $request->session()->get('mail_test_result'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* One form, one save: the notification toggle, admin recipients, and
|
|
* SMTP transport all persist together.
|
|
*/
|
|
public function update(Request $request): RedirectResponse
|
|
{
|
|
$canConfigureTransport = $this->capabilities->has(Capability::EmailTransportConfigure);
|
|
|
|
// Peeked at before validation because it decides which rule set
|
|
// the rest of the transport fields get; an unknown value falls
|
|
// through to the SMTP rules, whose `provider` rule then rejects
|
|
// it with the proper validation error.
|
|
$requestedProvider = $canConfigureTransport
|
|
? MailProvider::tryFrom((string) $request->input('provider'))
|
|
: null;
|
|
$wantsOAuth = $requestedProvider?->isOAuth() ?? false;
|
|
|
|
$rules = [
|
|
'email_notifications_enabled' => ['required', 'boolean'],
|
|
'admin_notification_emails' => ['required', 'array', 'min:1'],
|
|
'admin_notification_emails.*' => ['email', 'max:255'],
|
|
// With an OAuth provider the sender is the connected mailbox,
|
|
// not a form field — the form doesn't submit one.
|
|
'from_address' => [$wantsOAuth ? 'nullable' : 'required', 'email', 'max:255'],
|
|
'from_name' => ['required', 'string', 'max:255'],
|
|
];
|
|
|
|
if ($canConfigureTransport) {
|
|
$rules['provider'] = ['required', Rule::in(array_map(fn (MailProvider $p): string => $p->value, MailProvider::cases()))];
|
|
|
|
if ($wantsOAuth) {
|
|
$rules += [
|
|
'client_id' => ['required', 'string', 'max:255'],
|
|
'client_secret' => ['nullable', 'string', 'max:255'],
|
|
'tenant_id' => ['nullable', 'string', 'max:255'],
|
|
];
|
|
} else {
|
|
$rules += [
|
|
'host' => ['required', 'string', 'max:255'],
|
|
'port' => ['required', 'integer', 'between:1,65535'],
|
|
'username' => ['nullable', 'string', 'max:255'],
|
|
'password' => ['nullable', 'string', 'max:255'],
|
|
'encryption' => ['required', Rule::in(['none', 'tls', 'ssl'])],
|
|
];
|
|
}
|
|
}
|
|
|
|
$validated = $request->validate($rules);
|
|
|
|
$adminEmails = array_values(array_unique($validated['admin_notification_emails']));
|
|
$this->settings->set(Setting::EmailNotificationsEnabled, $validated['email_notifications_enabled']);
|
|
$this->settings->set(Setting::AdminNotificationEmails, $adminEmails);
|
|
|
|
$mailProvider = MailProviderSettings::current();
|
|
|
|
// Transport fields are simply never read from the request when the
|
|
// capability is absent — a hand-crafted PATCH can't smuggle a
|
|
// custom relay into a cloud install through this endpoint either.
|
|
if ($canConfigureTransport && $requestedProvider !== null) {
|
|
$mailProvider->provider = $requestedProvider;
|
|
|
|
if ($wantsOAuth) {
|
|
// The SMTP columns keep their values — switching to an
|
|
// OAuth provider and back must lose nothing.
|
|
$connection = MailOAuthConnection::for($requestedProvider);
|
|
|
|
// A different app registration invalidates tokens minted
|
|
// by the old one (the next refresh would present the new
|
|
// client_id against them and die) — drop them now so the
|
|
// page honestly shows "not connected" instead of a
|
|
// connection that fails on first send.
|
|
$clientIdChanged = $connection->client_id !== null
|
|
&& $connection->client_id !== ''
|
|
&& $connection->client_id !== $validated['client_id'];
|
|
|
|
$connection->client_id = $validated['client_id'];
|
|
$connection->tenant_id = ($validated['tenant_id'] ?? null) !== null && trim((string) $validated['tenant_id']) !== ''
|
|
? trim((string) $validated['tenant_id'])
|
|
: null;
|
|
|
|
// A blank secret keeps whatever is already stored, like
|
|
// the SMTP password below (only `has_client_secret` is
|
|
// ever round-tripped to the browser).
|
|
if (is_string($validated['client_secret'] ?? null) && $validated['client_secret'] !== '') {
|
|
$connection->client_secret = $validated['client_secret'];
|
|
}
|
|
|
|
if ($clientIdChanged) {
|
|
$connection->fill([
|
|
'access_token' => null,
|
|
'refresh_token' => null,
|
|
'token_expires_at' => null,
|
|
'account_email' => null,
|
|
]);
|
|
$connection->clearFailure();
|
|
}
|
|
|
|
$connection->save();
|
|
} else {
|
|
$mailProvider->fill([
|
|
'host' => $validated['host'],
|
|
'port' => $validated['port'],
|
|
'username' => $validated['username'] ?? null,
|
|
'encryption' => $validated['encryption'],
|
|
]);
|
|
|
|
// A blank password keeps whatever is already stored — the field
|
|
// is never round-tripped to the browser (only `has_password` is).
|
|
if (is_string($validated['password'] ?? null) && $validated['password'] !== '') {
|
|
$mailProvider->password = $validated['password'];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Absent while an OAuth provider is selected (the connected
|
|
// mailbox is the sender) — the stored value survives for a later
|
|
// switch back to SMTP.
|
|
if (is_string($validated['from_address'] ?? null) && $validated['from_address'] !== '') {
|
|
$mailProvider->from_address = $validated['from_address'];
|
|
}
|
|
|
|
$mailProvider->from_name = $validated['from_name'];
|
|
$mailProvider->save();
|
|
|
|
$this->mailConfig->flush();
|
|
$this->mailConfig->apply();
|
|
|
|
// The long-running queue worker cached the old config at boot;
|
|
// this signals it to restart so queued/future mail uses the new
|
|
// settings without a manual container restart.
|
|
Artisan::call('queue:restart');
|
|
|
|
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'email']);
|
|
|
|
return back()->with('success', __('Email settings saved.'));
|
|
}
|
|
|
|
/**
|
|
* Sends immediately (bypassing the queue, unlike every other
|
|
* notification in this app) to whatever address the admin enters,
|
|
* regardless of the toggle above — this exists specifically to
|
|
* verify SMTP works before switching it on, and needs a real,
|
|
* synchronous result to show, not a "queued" success message.
|
|
*/
|
|
public function sendTest(Request $request): RedirectResponse
|
|
{
|
|
abort_unless($this->capabilities->has(Capability::EmailTransportConfigure), 404);
|
|
|
|
$validated = $request->validate([
|
|
'recipient' => ['required', 'email', 'max:255'],
|
|
]);
|
|
|
|
// What "via" means depends on the active transport: host:port
|
|
// only describes SMTP; an OAuth mailer is best named by its
|
|
// mailer key (e.g. "microsoft-graph").
|
|
$mailer = config('mail.default');
|
|
|
|
if ($mailer === 'smtp') {
|
|
$host = config('mail.mailers.smtp.host');
|
|
$port = config('mail.mailers.smtp.port');
|
|
$hostPort = (is_string($host) ? $host : '').':'.(is_scalar($port) ? (string) $port : '');
|
|
} else {
|
|
$hostPort = is_string($mailer) ? $mailer : '';
|
|
}
|
|
|
|
// Which of the two this is has to travel with the message rather
|
|
// than be inferred from its text: the frontend colours the result,
|
|
// and sniffing for the word "Success" would stop working the
|
|
// moment somebody reads this screen in another language.
|
|
try {
|
|
Notification::route('mail', $validated['recipient'])->notifyNow(new TestEmailNotification);
|
|
|
|
$result = [
|
|
'ok' => true,
|
|
'message' => __('Success: test email sent to :email via :hostport.', [
|
|
'email' => $validated['recipient'],
|
|
'hostport' => $hostPort,
|
|
]),
|
|
];
|
|
} catch (Throwable $e) {
|
|
$result = ['ok' => false, 'message' => __('Failed to send: :error', ['error' => $e->getMessage()])];
|
|
}
|
|
|
|
return back()->with('mail_test_result', $result);
|
|
}
|
|
}
|