@Mail Domain Check

Plane self-hosted | SMTP admin | Test email

Plane SMTP Test Email Returns 500 Before Settings Are Saved

Plane's test modal sends only the receiver address, while the backend reloads persisted SMTP settings. If the visible form is valid but still unsaved, an empty stored EMAIL_PORT reaches int() before the endpoint enters its SMTP exception handler and the request becomes HTTP 500 without contacting an SMTP server.

Target incident

The SMTP form contains a valid host and port, but Send test email is clicked before Save changes. The backend still sees the old empty port and fails before connection construction is guarded.

Separate the form draft from the tested configuration

Browser draft
The valid values currently visible in the admin form. They are not durable until Save changes succeeds.
Persisted settings
The instance configuration returned by get_email_configuration() inside the test endpoint.
Configuration gate
Host, port, sender, security mode, and credential shape must be validated before an SMTP object or socket is created.
SMTP outcome
Connection, TLS, authentication, sender, recipient, and final submission exist only after the configuration gate passes.

Browser-local configuration planner

Find the first broken state boundary

No receiver address, SMTP host, port value, username, password, sender address, message, cookie, authorization header, complete configuration, provider payload, or raw log is requested or transmitted.

What the reviewed Plane paths do

The open Plane issue 9472 reports that Send test email returns HTTP 500 when SMTP fields are filled but not saved. At reviewed commit a8e53b6ac7b87bd8e3e931d21188f7679c7ab6c4, the admin form renders independent Save changes and Send test email buttons. The test button is disabled for an invalid form, but not for a valid dirty form.

The test modal calls sendTestEmail(receiverEmail); it is not passed the current host, port, security mode, sender, username, or password draft. The request therefore tests the saved instance configuration, not the values visible behind the modal.

On the backend, EmailCredentialCheckEndpoint.post() reloads the saved settings and executes port=int(EMAIL_PORT) while constructing the connection. That conversion is above the try beginning at line 120, so ValueError bypasses every bounded SMTP error response.

Make save-before-test explicit and validate before SMTP

For the current receiver-only endpoint, the smallest coherent UI contract is to block the test while isDirty is true. A visible saved-state requirement prevents a successful test of stale settings from being mistaken for proof about the current draft.

The backend remains an independent trust boundary. It should validate the persisted port and other required settings before constructing a Django email connection. Empty, malformed, zero, negative, and greater-than-65535 ports should return a stable 4xx response with no SMTP attempt.

raw_port = str(EMAIL_PORT or "").strip()
try:
    port = int(raw_port)
except (TypeError, ValueError):
    return Response(
        {"error": "Save a numeric SMTP port before sending a test email."},
        status=status.HTTP_400_BAD_REQUEST,
    )

if not 1 <= port <= 65535:
    return Response(
        {"error": "SMTP port must be between 1 and 65535."},
        status=status.HTTP_400_BAD_REQUEST,
    )

try:
    connection = get_connection(
        host=EMAIL_HOST,
        port=port,
        username=EMAIL_HOST_USER,
        password=EMAIL_HOST_PASSWORD,
        use_tls=EMAIL_USE_TLS == "1",
        use_ssl=EMAIL_USE_SSL == "1",
    )
    # Build and send the test message inside the guarded path.
except SMTPAuthenticationError:
    # Keep the existing bounded provider-specific response.
    ...

An alternative draft-test endpoint can be valid, but it is a larger contract: it must validate every draft field server-side, define how an unchanged masked password reuses the stored secret, avoid persisting the draft, and never echo credentials. Mixing the receiver-only endpoint with the visible draft is the unsafe middle state.

Regression matrix

StateExpected boundaryProof
Valid dirty formNo requestTest remains blocked until Save changes succeeds
Persisted empty or whitespace portConfiguration 4xxNo get_connection or socket call
Persisted nonnumeric, 0, or 65536 portConfiguration 4xxStable error shape without raw exception data
Saved valid port with bad credentialsSMTP authentication responseExisting bounded invalid-credentials path remains reachable
Saved valid configurationMessage submissionHTTP 200 only after msg.send(fail_silently=False) completes
Save failsNo requestTest remains blocked and no stale success is displayed

A successful test is not inbox proof

Once the saved configuration reaches msg.send(), preserve the exact SMTP result. A successful return establishes that Plane completed its configured send path; it does not prove the message became visible in the recipient's inbox. If the endpoint returns 200 but nothing arrives, continue with the provider and recipient handoff guide.

Minimal evidence to keep

  • Whether the form was dirty and whether Save changes completed.
  • A non-secret configuration revision marker shared by save and test.
  • The persisted port category: empty, invalid, or valid. Do not publish the value.
  • Whether the request was blocked, returned configuration 4xx, reached SMTP, or returned 200.
  • The bounded exception class or provider reply category, without a receiver, credential, message, or full traceback.
Privacy boundary

This planner runs locally. Do not enter or publish a receiver address, SMTP host, port value, username, password, sender address, message, cookie, authorization header, complete configuration, provider payload, or raw log.

Primary references