Files
warmbly/internal/app/instancecheck/checks_mail.go
T
Matthew Meszaros 734cb5fe08 feat: make self-hosted onboarding survivable by fixing invite_only, which could not onboard anyone (the accept route is JWT-only, so redeeming the invitation that would create your account required already having one, making the self-host default silently identical to fully closed), threading the invitation token through registration so an invited person lands in the inviting organization instead of a stray workspace, gating SSO just-in-time provisioning behind DISABLE_REGISTRATION (it bypassed the gate entirely, so an instance set to true was still open to anyone the IdP would assert) with SSO_AUTO_PROVISION as the opt-out, correcting the OIDC redirect URL that pointed at /api/v1 against a route at /v1 and 404'd every SSO login, scoping the first-launch exemption so it no longer overrides an explicit lockdown, preserving the remaining TTL when restoring a losing setup token so a public endpoint cannot hold the claim window open forever, replacing a generic 403 with typed registration_invite_only, registration_closed, invitation_invalid, setup_token_invalid and setup_already_complete codes that name the next step, logging why no claim link was issued on an already-claimed instance instead of staying silent, adding a warmblyctl operator CLI (status with health checks and a non-zero exit, reissuable setup-link, user create/list/reset-password/grant-admin/revoke-admin/disable-2fa, hash-password) so a locked-out operator no longer needs hand-written psql, adding read-only instance configuration over 104 environment variables with structural secret redaction and fingerprints, 35 health checks, a database-backed settings tier for the three keys no environment variable owns, hiding the signup form when the config already says invite_only rather than failing the whole form with a toast, and documenting first run, accounts and access, configuration, instance health and troubleshooting alongside the root .env.example the README told operators to write but never shipped (#114)
2026-08-16 05:58:11 +02:00

100 lines
3.5 KiB
Go

package instancecheck
import (
"context"
"fmt"
"net/mail"
"strings"
"github.com/warmbly/warmbly/internal/config"
)
const docsLoginCodes = "/development/accounts-and-access/#login-codes"
func mailChecks() []check {
return []check{
{id: "mail_transport_log", run: checkMailTransportLog},
{id: "mail_preflight_failed", run: checkMailPreflightFailed},
{id: "mail_identity_unset", run: checkMailIdentityUnset},
{id: "mail_from_domain_mismatch", run: checkMailFromDomainMismatch},
{id: "login_code_demoted", run: checkLoginCodeDemoted},
}
}
func checkMailTransportLog(ctx context.Context, d Deps, in Input) *Finding {
if mailDelivers(d) {
return nil
}
severity := SeverityWarning
if isLoopbackURL(appURL()) {
severity = SeverityInfo
}
return result(CategoryMail, severity, "Platform mail is not delivered",
"Platform mail is not being delivered. MAIL_TRANSPORT=log writes every message to the backend log instead. "+
"Login codes, password resets, team invitations and notification digests will never arrive. "+
"Invitations still work: copy the invite link from Settings > Members and send it yourself.",
docsMail)
}
func checkMailPreflightFailed(ctx context.Context, d Deps, in Input) *Finding {
// One incident is one row: a transport that does not deliver already has
// its own finding, so do not also report that it will not dial.
if d.Transport == nil || !d.Transport.Delivers {
return nil
}
err := d.Transport.Preflight(ctx)
if err == nil {
return nil
}
return result(CategoryMail, SeverityError, "The mail relay did not accept a connection",
fmt.Sprintf("The mail relay did not accept a connection: %s. "+
"Nobody can reset a password or receive an invitation until this is fixed.", err.Error()),
docsMail)
}
func checkMailIdentityUnset(ctx context.Context, d Deps, in Input) *Finding {
if env("EMAIL_NAME") != "" && env("EMAIL_ADDRESS") != "" {
return nil
}
return result(CategoryMail, SeverityError, "Platform mail identity is missing",
"EMAIL_ADDRESS or EMAIL_NAME is missing. The backend refuses to start without them, "+
"but the consumer only warns and silently disables all notification and digest email, "+
"so this instance can look healthy while sending nothing.",
docsMail)
}
func checkMailFromDomainMismatch(ctx context.Context, d Deps, in Input) *Finding {
address := env("EMAIL_ADDRESS")
dashboard := hostOf(appURL())
if address == "" || dashboard == "" || !appURLConfigured() {
return nil
}
parsed, err := mail.ParseAddress(address)
if err != nil {
return nil
}
at := strings.LastIndex(parsed.Address, "@")
if at < 0 {
return nil
}
if registrableDomain(parsed.Address[at+1:]) == registrableDomain(dashboard) {
return nil
}
return result(CategoryMail, SeverityInfo, "Mail sender domain does not match the dashboard",
fmt.Sprintf("Platform mail is sent from %s while the dashboard is at %s. "+
"Mailbox providers may treat that as a mismatch. This is only a warning if you intended them to differ.",
parsed.Address, dashboard),
docsMail)
}
func checkLoginCodeDemoted(ctx context.Context, d Deps, in Input) *Finding {
requested := strings.ToLower(env("AUTH_LOGIN_CODE"))
if requested != config.LoginCodeAlways || mailDelivers(d) {
return nil
}
return result(CategoryMail, SeverityInfo, "Login codes were demoted",
"AUTH_LOGIN_CODE is set to always, but the mail transport does not deliver, so it has been demoted to new_device. "+
"Otherwise nobody could ever complete a login.",
docsLoginCodes)
}