mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-08 08:03:28 +00:00
feat: let a self-hosted instance connect a mail server on its own machine by adding a third mailbox security mode, "none", accepted only for a loopback literal and only where the worker shares a host with the relay, so Proton Bridge on 127.0.0.1:1143/1025 and a local Dovecot or Mailpit can be connected at all, with the rule enforced in onboarding and reauth validation, again by the worker against the peer it actually dialled rather than the name it was given, and hidden from the connect form on the hosted product where the worker is never the customer's machine
This commit is contained in:
@@ -694,7 +694,9 @@ Connects an SMTP/IMAP mailbox in a single call.
|
||||
| `smtp` | object | yes | SMTP credentials: `username`, `password`, `host`, `port`, `security`. |
|
||||
| `imap` | object | yes | IMAP credentials: `username`, `password`, `host`, `port`, `security`. |
|
||||
|
||||
`security` is `tls` (implicit TLS, encrypted from the first byte) or `starttls` (plaintext greeting upgraded in-band). It is optional: omit it and the port decides, which is `tls` for SMTP 465 and IMAP 993, and `starttls` for SMTP 587 and IMAP 143. Set it explicitly for anything non-standard, such as a submission relay on 2525. Any port from 1 to 65535 is accepted; TLS itself is not optional.
|
||||
`security` is `tls` (implicit TLS, encrypted from the first byte), `starttls` (plaintext greeting upgraded in-band) or `none` (no encryption). It is optional: omit it and the port decides, which is `tls` for SMTP 465 and IMAP 993, and `starttls` for SMTP 587 and IMAP 143. `none` is never inferred from a port, only obeyed when you ask for it. Set the mode explicitly for anything non-standard, such as a submission relay on 2525. Any port from 1 to 65535 is accepted.
|
||||
|
||||
`none` is accepted only for a mail server on the same machine as the worker, which means a self-hosted instance and a `host` of `localhost`, an address in `127.0.0.0/8`, or `::1`. It exists for local relays such as Proton Bridge, which serve plain IMAP and SMTP on the loopback interface; see [local mail relays](/guides/mailboxes/#local-mail-relays-proton-bridge). A hostname that merely resolves to a loopback address is refused, the request is rejected with `400` on the hosted product, and the worker checks the peer again at dial time. Everywhere else TLS is not optional.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -310,6 +310,14 @@ Stops and removes the containers and leaves every byte of data where it is. Addi
|
||||
|
||||
More in [troubleshooting](/development/troubleshooting/) and [instance health](/development/instance-health/).
|
||||
|
||||
## Mail on the same machine
|
||||
|
||||
A self-hosted instance can connect a mailbox that only exists on the machine it runs on. [Proton Bridge](https://proton.me/mail/bridge) is the case that matters: it signs in to Proton Mail locally and re-serves the account as plain IMAP on `127.0.0.1:1143` and SMTP on `127.0.0.1:1025`, speaking no TLS a client can verify because it never listens on a network interface.
|
||||
|
||||
Connecting one means choosing the **None** security mode in the connect form, which Warmbly offers only when the host is a loopback literal (`localhost`, `127.0.0.0/8`, `::1`) and only on a self-hosted instance. The credentials never leave the machine, which is the entire reason it is allowed; the worker checks the peer it actually dialled as well, so an edit to the stored host cannot widen it afterwards. Hosted Warmbly runs its workers in our infrastructure and never offers the mode.
|
||||
|
||||
The worker has to be the one on that machine. A `docker compose` install puts it there by default; a [dedicated worker](/development/architecture/) on another host cannot reach the Bridge. Full detail in the [mailboxes guide](/guides/mailboxes/#local-mail-relays-proton-bridge).
|
||||
|
||||
## Next
|
||||
|
||||
- [First run](/development/first-run/): claiming the instance, the first owner, demo data
|
||||
|
||||
@@ -77,6 +77,8 @@ Newer builds return the invite-only refusal with its own machine code, `registra
|
||||
| Workers or tracking get 401s | `INTERNAL_API_TOKEN` must match on the backend, the workers (as `ENCRYPTED_KEYS_WORKER_TOKEN`) and tracking. Unset fails closed |
|
||||
| "No mailbox workers are available" when connecting a mailbox | No worker has a heartbeat inside the last 10 minutes. Check `make status` shows `worker` running and `make logs worker` is clean. An empty `ENCRYPTED_KEYS_BACKEND_URL` or worker token lets a worker start and never register, silently |
|
||||
| Connecting a mailbox fails with `SERVER_UNREACHABLE` on a reachable host | The security setting does not match the server. A server expecting STARTTLS looks unreachable to a client attempting implicit TLS, and vice versa. Any port from 1 to 65535 is accepted, so the port alone no longer decides: set **Security** to SSL / TLS for a server that is encrypted from the first byte (usually SMTP `465`, IMAP `993`) and STARTTLS for one that upgrades in place (usually SMTP `587` or `2525`, IMAP `143`) |
|
||||
| A local relay such as Proton Bridge will not connect, and the **None** security option is not there | It appears only for a loopback literal (`localhost`, `127.0.0.0/8`, `::1`) on a self-hosted instance, because the mode is safe only when the credentials never leave the machine. A hostname that resolves to `127.0.0.1` is refused; type the address itself. On hosted Warmbly the worker is not your machine and cannot reach a Bridge at all. See [local mail relays](/guides/mailboxes/#local-mail-relays-proton-bridge) |
|
||||
| A mailbox reports `INSECURE_REMOTE_HOST` | It is stored with the unencrypted security mode but its host is not this machine, so the worker refused to dial it rather than put the password on a wire. Reconnect it with SSL / TLS or STARTTLS, or point it back at the local relay |
|
||||
| A mailbox stalls after about an hour | The worker is missing `BOX_GOOGLE_*` or `BOX_OUTLOOK_*`. The backend starts the OAuth flow but each worker refreshes the token. Set them and restart the worker |
|
||||
| Sending fails with an authentication error but the password is right | Warmbly negotiates the sign-in method from what the server advertises. If the mailbox reports `AUTH_UNSUPPORTED`, the server offers only mechanisms Warmbly does not implement, such as NTLM or GSSAPI, or it offers no encrypted connection at all and Warmbly will not send the password in the clear; an app password, or the provider's documented SMTP host, usually resolves both |
|
||||
| A send is refused and not retried | A `SEND_REJECTED` or `RECIPIENT_REJECTED` error means the receiving server answered with a permanent `5xx`, so retrying cannot deliver the message and would only spend the mailbox's daily budget. Both carry the server's own words, which is what distinguishes an address that no longer exists from one blocked by a policy. A temporary `4xx` is retried automatically and reported as a connection problem |
|
||||
|
||||
@@ -30,8 +30,11 @@ Each side also has a **security** setting, which is what decides how the connect
|
||||
| --- | --- | --- |
|
||||
| SSL / TLS | Encrypted from the first byte | SMTP `465`, IMAP `993` |
|
||||
| STARTTLS | Connects in the clear, then upgrades in place before anything sensitive is sent | SMTP `587` or `2525`, IMAP `143` |
|
||||
| None | No encryption. Only offered for a mail server on the same machine as the worker | SMTP `1025`, IMAP `1143` |
|
||||
|
||||
The form picks the right one from the port as you type, so standard setups need no thought. Change it yourself when your server is unusual: any port from 1 to 65535 works, so a submission relay on `2525` or IMAP on a custom port is fine as long as the security setting matches what the server actually speaks. Encryption itself is not optional; Warmbly will not send credentials over an unencrypted connection.
|
||||
The form picks the right one from the port as you type, so standard setups need no thought. Change it yourself when your server is unusual: any port from 1 to 65535 works, so a submission relay on `2525` or IMAP on a custom port is fine as long as the security setting matches what the server actually speaks.
|
||||
|
||||
Encryption is not optional over a network. Warmbly will not send credentials over an unencrypted connection to anything but this machine, which is why **None** only appears once the host is `localhost`, `127.0.0.1` or `::1` and only on a [self-hosted instance](/development/install/). See [local mail relays](#local-mail-relays-proton-bridge) below.
|
||||
|
||||
If a mailbox fails to connect with a server-unreachable error and the host and port are definitely right, the security setting is the first thing to check. A server expecting STARTTLS looks unreachable to a client attempting implicit TLS, and vice versa.
|
||||
|
||||
@@ -45,6 +48,19 @@ Warmbly signs in with whichever method your server offers, preferring CRAM-MD5,
|
||||
Credentials and both connections are validated when you add the account, so wrong settings fail immediately rather than silently at send time. Tokens and credentials are sealed with envelope encryption before they touch storage.
|
||||
</Callout>
|
||||
|
||||
### Local mail relays (Proton Bridge)
|
||||
|
||||
Some mail is only reachable through a program running on your own machine. [Proton Bridge](https://proton.me/mail/bridge) is the common one: it signs in to Proton Mail for you and re-serves the account as ordinary IMAP on `127.0.0.1:1143` and SMTP on `127.0.0.1:1025`. It speaks no TLS a client can verify, because it never listens on a network interface in the first place.
|
||||
|
||||
Warmbly connects to one of these with the **None** security mode, under two conditions that are checked when you save the mailbox and again every time the worker dials it:
|
||||
|
||||
- the host is a loopback literal: `localhost`, an address in `127.0.0.0/8`, or `::1`. A hostname that merely resolves to one is refused, because what a name resolves to can change after it is checked
|
||||
- the instance is self-hosted, and the [worker](/development/architecture/) runs on the same machine as the relay
|
||||
|
||||
Both are about the same thing: the credentials never leave the machine, so there is no wire to intercept them on. On hosted Warmbly the worker is in our infrastructure, never on your computer, so a local relay is not reachable from it and the mode is not offered. Run a [self-hosted instance](/development/install/) on the machine the Bridge is on if you need it.
|
||||
|
||||
Set the Bridge's own username and password, which it shows in its interface, not your Proton account password. The same applies to any relay on the machine: a Dovecot sharing a host with its worker, or a local sink used for testing.
|
||||
|
||||
## Mailbox allowance
|
||||
|
||||
Mailboxes are unlimited on every paid plan. What keeps that honest is a fair-use allowance derived from the plan's sending volume: one mailbox for every send a day the plan includes.
|
||||
@@ -84,7 +100,7 @@ Pick **Bulk import from CSV** in the connect dialog to connect any number of SMT
|
||||
| `smtp_port`, `imap_port` | `587` and `993` |
|
||||
| `smtp_user`, `imap_user` | the address, or a shared `username` column |
|
||||
| `smtp_password`, `imap_password` | a shared `password` column |
|
||||
| `smtp_security`, `imap_security` | inferred from the port (`465` and `993` are `tls`, `587` and `143` are `starttls`) |
|
||||
| `smtp_security`, `imap_security` | inferred from the port (`465` and `993` are `tls`, `587` and `143` are `starttls`); `none` is accepted only for a loopback host |
|
||||
|
||||
The dialog offers a template with these headers, and accepts the common spellings (`smtp_server`, `app_password`, `login`, and so on).
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
@@ -322,6 +323,14 @@ func validPort(port int) bool {
|
||||
|
||||
// validateMailSecurity rejects an unknown security mode. Empty is allowed and
|
||||
// means "infer from the port", which is how existing clients behave.
|
||||
//
|
||||
// "none" carries two extra conditions, because it is the one mode that puts a
|
||||
// password on an unencrypted socket. It is legal only against a loopback host,
|
||||
// where the socket never reaches a wire, and only on a self-hosted instance,
|
||||
// where the worker runs on the operator's own machine. On the hosted product
|
||||
// the worker is never the customer's machine, so a loopback address there is
|
||||
// the WORKER's loopback: the mode could not reach the relay it was meant for
|
||||
// and would only be a way to speak plaintext to whatever answers on that port.
|
||||
func validateMailSecurity(smtp, imap *models.Service) *errx.Error {
|
||||
if smtp.Security != "" && !models.ValidMailSecurity(smtp.Security) {
|
||||
return errx.ErrEmailSMTPSecurity
|
||||
@@ -329,6 +338,23 @@ func validateMailSecurity(smtp, imap *models.Service) *errx.Error {
|
||||
if imap.Security != "" && !models.ValidMailSecurity(imap.Security) {
|
||||
return errx.ErrEmailIMAPSecurity
|
||||
}
|
||||
if err := validateCleartextHost(smtp.Security, smtp.Host, errx.ErrEmailSMTPSecurityNotLocal, errx.ErrEmailSMTPSecurityHosted); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateCleartextHost(imap.Security, imap.Host, errx.ErrEmailIMAPSecurityNotLocal, errx.ErrEmailIMAPSecurityHosted)
|
||||
}
|
||||
|
||||
// validateCleartextHost is the "none" gate for one leg.
|
||||
func validateCleartextHost(security, host string, notLocal, hosted *errx.Error) *errx.Error {
|
||||
if security != models.MailSecurityNone {
|
||||
return nil
|
||||
}
|
||||
if !config.SelfHosted() {
|
||||
return hosted
|
||||
}
|
||||
if !models.LoopbackMailHost(host) {
|
||||
return notLocal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -75,3 +75,40 @@ func TestOAuthConfigFor_ConfiguredProviderIsReturned(t *testing.T) {
|
||||
t.Errorf("outlook should still be unconfigured, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The unencrypted mailbox mode has two gates and they answer different
|
||||
// questions: whether this deployment can host a local relay at all, and
|
||||
// whether the host given is that relay. A user who gets the wrong one back is
|
||||
// sent to fix the wrong thing.
|
||||
func TestValidateMailSecurity_CleartextIsLoopbackOnlyAndSelfHostOnly(t *testing.T) {
|
||||
svc := func(host, security string) *models.Service {
|
||||
return &models.Service{Host: host, Port: 1143, Security: security}
|
||||
}
|
||||
tls := svc("imap.example.com", models.MailSecurityTLS)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
deployment string
|
||||
smtp, imap *models.Service
|
||||
want *errx.Error
|
||||
}{
|
||||
{"self-host, loopback imap", "self_hosted", svc("smtp.example.com", models.MailSecurityStartTLS), svc("127.0.0.1", models.MailSecurityNone), nil},
|
||||
{"self-host, localhost smtp", "self_hosted", svc("localhost", models.MailSecurityNone), tls, nil},
|
||||
{"self-host, ipv6 loopback", "self_hosted", svc("::1", models.MailSecurityNone), tls, nil},
|
||||
{"self-host, remote imap", "self_hosted", svc("smtp.example.com", models.MailSecurityTLS), svc("imap.proton.me", models.MailSecurityNone), errx.ErrEmailIMAPSecurityNotLocal},
|
||||
{"self-host, remote smtp", "self_hosted", svc("mail.example.com", models.MailSecurityNone), tls, errx.ErrEmailSMTPSecurityNotLocal},
|
||||
// Hosted: the worker is not the customer's machine, so a loopback
|
||||
// address there is the worker's own and the mode is refused outright.
|
||||
{"hosted, loopback smtp", "cloud", svc("127.0.0.1", models.MailSecurityNone), tls, errx.ErrEmailSMTPSecurityHosted},
|
||||
{"hosted, loopback imap", "cloud", svc("smtp.example.com", models.MailSecurityTLS), svc("localhost", models.MailSecurityNone), errx.ErrEmailIMAPSecurityHosted},
|
||||
{"unknown mode still rejected", "self_hosted", svc("localhost", "plain"), tls, errx.ErrEmailSMTPSecurity},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("DEPLOYMENT_MODE", tc.deployment)
|
||||
got := validateMailSecurity(tc.smtp, tc.imap)
|
||||
if got != tc.want {
|
||||
t.Fatalf("validateMailSecurity() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,3 +81,21 @@ func InsecureTLS() bool {
|
||||
})
|
||||
return insecureTLS
|
||||
}
|
||||
|
||||
// LoopbackPeer reports whether conn's peer is this machine.
|
||||
//
|
||||
// The host string was already checked before dialing, but a name check is a
|
||||
// promise about DNS, not about the socket: the answer can change between the
|
||||
// two. This asks the connection itself, which is the only thing that cannot
|
||||
// be moved after the fact, and is what makes the cleartext mailbox mode
|
||||
// (models.MailSecurityNone) safe to obey.
|
||||
func LoopbackPeer(conn net.Conn) bool {
|
||||
if conn == nil {
|
||||
return false
|
||||
}
|
||||
addr, ok := conn.RemoteAddr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return addr.IP.IsLoopback()
|
||||
}
|
||||
|
||||
@@ -163,17 +163,28 @@ func (c *Client) connectLocked() *errx.MailError {
|
||||
if timeout <= 0 {
|
||||
timeout = config.ImapCommandIdleTimeout
|
||||
}
|
||||
// The unencrypted mode is checked before the dial and again against the
|
||||
// peer we actually got, because only the second one is a fact about this
|
||||
// socket rather than about what DNS said a moment ago.
|
||||
resolved := models.ResolveIMAPSecurity(security, port)
|
||||
if resolved == models.MailSecurityNone && !models.LoopbackMailHost(host) {
|
||||
return errx.ErrMailInsecureRemoteHost
|
||||
}
|
||||
raw, err := netbind.Dialer(c.BindIP).DialContext(context.Background(), "tcp", addr)
|
||||
if err != nil {
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
if resolved == models.MailSecurityNone && !netbind.LoopbackPeer(raw) {
|
||||
_ = raw.Close()
|
||||
return errx.ErrMailInsecureRemoteHost
|
||||
}
|
||||
conn := &idleConn{Conn: raw, timeout: timeout}
|
||||
|
||||
var client *imapclient.Client
|
||||
switch {
|
||||
case c.plaintext:
|
||||
case c.plaintext, resolved == models.MailSecurityNone:
|
||||
client = imapclient.New(conn, nil)
|
||||
case models.ResolveIMAPSecurity(security, port) == models.MailSecurityStartTLS:
|
||||
case resolved == models.MailSecurityStartTLS:
|
||||
// Plaintext greeting, upgraded in-band. NewStartTLS closes conn
|
||||
// itself when the upgrade fails.
|
||||
client, err = imapclient.NewStartTLS(conn, &imapclient.Options{TLSConfig: tlsConf})
|
||||
|
||||
@@ -3,9 +3,10 @@ package smtp
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// ErrSMTPCleartextAuth is returned rather than sending credentials over an
|
||||
@@ -93,13 +94,9 @@ func (a *LoginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
}
|
||||
|
||||
// IsLoopbackHost reports whether host is this machine, where credentials
|
||||
// cannot reach a wire even without TLS.
|
||||
// cannot reach a wire even without TLS. One definition, shared with the
|
||||
// mailbox security modes, so the AUTH guard and the "none" mode can never
|
||||
// disagree about what counts as local.
|
||||
func IsLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
return models.LoopbackMailHost(host)
|
||||
}
|
||||
|
||||
@@ -272,7 +272,14 @@ func (c *Client) sendRaw(ctx context.Context, from string, to []string, data []b
|
||||
// Implicit TLS (SMTPS) means the server speaks TLS from the first byte, so
|
||||
// a plaintext dial + STARTTLS never gets past the greeting. The mode is
|
||||
// the mailbox's stored choice, falling back to the port convention.
|
||||
implicitTLS := models.ResolveSMTPSecurity(security, port) == models.MailSecurityTLS
|
||||
resolved := models.ResolveSMTPSecurity(security, port)
|
||||
// The unencrypted mode is checked before the dial and again against the
|
||||
// peer we actually got, because only the second one is a fact about this
|
||||
// socket rather than about what DNS said a moment ago.
|
||||
if resolved == models.MailSecurityNone && !models.LoopbackMailHost(host) {
|
||||
return errx.ErrMailInsecureRemoteHost
|
||||
}
|
||||
implicitTLS := resolved == models.MailSecurityTLS
|
||||
if implicitTLS {
|
||||
conn, err = netbind.TLSDialer(c.BindIP, tlsConf).DialContext(ctx, "tcp", addr)
|
||||
} else {
|
||||
@@ -282,6 +289,9 @@ func (c *Client) sendRaw(ctx context.Context, from string, to []string, data []b
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
defer conn.Close()
|
||||
if resolved == models.MailSecurityNone && !netbind.LoopbackPeer(conn) {
|
||||
return errx.ErrMailInsecureRemoteHost
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
@@ -301,10 +311,13 @@ func (c *Client) sendRaw(ctx context.Context, from string, to []string, data []b
|
||||
}
|
||||
}
|
||||
|
||||
// TLS is mandatory. The MAIL_TLS_INSECURE dev knob additionally allows a
|
||||
// server with no STARTTLS at all (the local mailpit sink) — never taken in
|
||||
// production, where the env var is unset.
|
||||
if !implicitTLS {
|
||||
// TLS is mandatory everywhere but the loopback mode, which was already
|
||||
// proved to be talking to this machine. STARTTLS is not attempted there
|
||||
// even when the relay advertises it: a self-signed certificate no client
|
||||
// can verify is exactly why the mode was chosen. The MAIL_TLS_INSECURE
|
||||
// dev knob additionally allows a server with no STARTTLS at all (the
|
||||
// local mailpit sink), never taken in production, where it is unset.
|
||||
if !implicitTLS && resolved != models.MailSecurityNone {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(tlsConf); err != nil {
|
||||
return errx.ErrMailServerUnreachable
|
||||
|
||||
+17
-1
@@ -18,12 +18,23 @@ import (
|
||||
func VerifyImap(ctx context.Context, host string, port int, user, pass, security string) bool {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
resolved := models.ResolveIMAPSecurity(security, port)
|
||||
// The unencrypted mode only ever addresses this machine, checked here and
|
||||
// again against the peer below, so a mailbox that could never be dialled
|
||||
// safely fails at connect rather than on the first sync.
|
||||
if resolved == models.MailSecurityNone && !models.LoopbackMailHost(host) {
|
||||
return false
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
if resolved == models.MailSecurityNone && !netbind.LoopbackPeer(conn) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Matches the sync client's TLS policy: MAIL_TLS_INSECURE is a dev-only
|
||||
// knob for the local self-signed sandbox, never set in production. Without
|
||||
@@ -35,7 +46,12 @@ func VerifyImap(ctx context.Context, host string, port int, user, pass, security
|
||||
|
||||
var c *imapclient.Client
|
||||
|
||||
if models.ResolveIMAPSecurity(security, port) == models.MailSecurityStartTLS {
|
||||
if resolved == models.MailSecurityNone {
|
||||
if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
return false
|
||||
}
|
||||
c = imapclient.New(conn, nil)
|
||||
} else if resolved == models.MailSecurityStartTLS {
|
||||
// The greeting arrives in cleartext and the upgrade happens in-band.
|
||||
if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
return false
|
||||
|
||||
+13
-2
@@ -32,7 +32,15 @@ func VerifySMTP(ctx context.Context, host string, port int, user, pass, security
|
||||
|
||||
// netbind dialers so validation probes leave from WORKER_BIND_IP exactly
|
||||
// like the sends they are vouching for.
|
||||
implicitTLS := models.ResolveSMTPSecurity(security, port) == models.MailSecurityTLS
|
||||
resolved := models.ResolveSMTPSecurity(security, port)
|
||||
// The unencrypted mode only ever addresses this machine. Refusing it here
|
||||
// as well as at send time means a mailbox that could never be dialled
|
||||
// safely fails at connect, where the user is standing in front of the
|
||||
// form, rather than at the first send.
|
||||
if resolved == models.MailSecurityNone && !models.LoopbackMailHost(host) {
|
||||
return false
|
||||
}
|
||||
implicitTLS := resolved == models.MailSecurityTLS
|
||||
if implicitTLS {
|
||||
conn, err = netbind.TLSDialer(nil, tlsConf).DialContext(ctx, "tcp", addr)
|
||||
} else {
|
||||
@@ -45,6 +53,9 @@ func VerifySMTP(ctx context.Context, host string, port int, user, pass, security
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
if resolved == models.MailSecurityNone && !netbind.LoopbackPeer(conn) {
|
||||
return false
|
||||
}
|
||||
|
||||
c, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
@@ -52,7 +63,7 @@ func VerifySMTP(ctx context.Context, host string, port int, user, pass, security
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if !implicitTLS {
|
||||
if !implicitTLS && resolved != models.MailSecurityNone {
|
||||
// TLS stays mandatory, with the same dev-only escape hatch the send
|
||||
// path uses for the local no-STARTTLS sink.
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
|
||||
@@ -126,8 +126,15 @@ var (
|
||||
ErrEmailReauthNoRefreshToken = New(BadRequest, "The provider did not return a refresh token and none is stored. Please try re-authorizing again.")
|
||||
ErrEmailSMTPHost = New(BadRequest, "SMTP host is required.")
|
||||
ErrEmailSMTPPort = New(BadRequest, "SMTP port must be between 1 and 65535.")
|
||||
ErrEmailSMTPSecurity = New(BadRequest, "SMTP security must be tls or starttls.")
|
||||
ErrEmailIMAPSecurity = New(BadRequest, "IMAP security must be tls or starttls.")
|
||||
ErrEmailSMTPSecurity = New(BadRequest, "SMTP security must be tls, starttls or none.")
|
||||
ErrEmailIMAPSecurity = New(BadRequest, "IMAP security must be tls, starttls or none.")
|
||||
// The "none" refusals are two different problems with two different ways
|
||||
// out, so they do not share a message: one is a host that has to change,
|
||||
// the other is a mode this deployment cannot offer at all.
|
||||
ErrEmailSMTPSecurityNotLocal = New(BadRequest, "SMTP security \"none\" is only allowed for a mail server on this machine (localhost, 127.0.0.1 or ::1).")
|
||||
ErrEmailIMAPSecurityNotLocal = New(BadRequest, "IMAP security \"none\" is only allowed for a mail server on this machine (localhost, 127.0.0.1 or ::1).")
|
||||
ErrEmailSMTPSecurityHosted = New(BadRequest, "SMTP security \"none\" needs a self-hosted instance, where the worker runs on the same machine as the mail server. Use tls or starttls.")
|
||||
ErrEmailIMAPSecurityHosted = New(BadRequest, "IMAP security \"none\" needs a self-hosted instance, where the worker runs on the same machine as the mail server. Use tls or starttls.")
|
||||
ErrEmailIMAPHost = New(BadRequest, "IMAP host is required.")
|
||||
ErrEmailIMAPPort = New(BadRequest, "IMAP port must be between 1 and 65535.")
|
||||
ErrEmailCredentialsRequired = New(BadRequest, "SMTP and IMAP credentials are required.")
|
||||
|
||||
@@ -64,6 +64,11 @@ const (
|
||||
// MailErrorCodeAuthUnsupported is a server whose advertised
|
||||
// authentication mechanisms we do not implement.
|
||||
MailErrorCodeAuthUnsupported MailErrorCode = "AUTH_UNSUPPORTED"
|
||||
// MailErrorCodeInsecureRemoteHost is the unencrypted mailbox mode aimed
|
||||
// at something that is not this machine. Kept apart from a credentials
|
||||
// or reachability failure because the server is fine and the password is
|
||||
// fine: the connection is one we refuse to make.
|
||||
MailErrorCodeInsecureRemoteHost MailErrorCode = "INSECURE_REMOTE_HOST"
|
||||
// MailErrorCodeDomainAuthRejected is the receiving side refusing the mail
|
||||
// because the SENDING DOMAIN failed its authentication bar (Outlook's
|
||||
// 5.7.515, Gmail's 5.7.26). Not a dead server and not a bad recipient:
|
||||
@@ -207,6 +212,17 @@ var (
|
||||
ErrMailSendRejected = func(detail string) *MailError {
|
||||
return MError(MailErrorWarning, MailErrorCodeSendRejected, fmt.Sprintf("The receiving mail server refused this message: %s", detail), MailErrorResolveMethodNone)
|
||||
}
|
||||
// ErrMailInsecureRemoteHost is the dial-time half of the loopback rule
|
||||
// for the unencrypted mailbox mode. The connect form enforces it too,
|
||||
// but the worker is the thing holding the socket, so it decides: an
|
||||
// instance-local edit to the stored host cannot talk a worker into
|
||||
// putting a password on a wire.
|
||||
ErrMailInsecureRemoteHost = MError(
|
||||
MailErrorCritical,
|
||||
MailErrorCodeInsecureRemoteHost,
|
||||
"This mailbox is set to connect without encryption, which Warmbly only does to a mail server on the same machine as the worker. Point it at localhost or choose SSL/TLS or STARTTLS.",
|
||||
MailErrorResolveMethodReload,
|
||||
)
|
||||
// ErrMailCleartextAuth is our own refusal to put a password on an
|
||||
// unencrypted wire, raised before anything is sent. Not retryable: no
|
||||
// number of attempts encrypts the link, and reporting it as an outage
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -147,10 +149,9 @@ type EmailAuthTransition struct {
|
||||
OrganizationID *uuid.UUID
|
||||
}
|
||||
|
||||
// Mail connection security modes. TLS is mandatory either way; the difference
|
||||
// is whether it is negotiated before the protocol greeting or upgraded in-band
|
||||
// after it. Storing the mode explicitly is what lets a mailbox live on any
|
||||
// port: inferring it from the port only ever worked for 465/587/993/143.
|
||||
// Mail connection security modes. Storing the mode explicitly is what lets a
|
||||
// mailbox live on any port: inferring it from the port only ever worked for
|
||||
// 465/587/993/143.
|
||||
const (
|
||||
// MailSecurityTLS is implicit TLS: the server speaks TLS from the first
|
||||
// byte. SMTP 465 (SMTPS), IMAP 993 (IMAPS).
|
||||
@@ -158,17 +159,51 @@ const (
|
||||
// MailSecurityStartTLS is a plaintext greeting upgraded in-band with
|
||||
// STARTTLS. SMTP 587/25/2525, IMAP 143.
|
||||
MailSecurityStartTLS = "starttls"
|
||||
// MailSecurityNone is no encryption at all, and is only ever legal
|
||||
// against a loopback host (see LoopbackMailHost). It exists for the
|
||||
// local relays that speak no TLS because they never listen on a network
|
||||
// interface: Proton Bridge on 127.0.0.1:1143/1025, a Dovecot sharing a
|
||||
// host with its worker, a Mailpit-style sink. The credentials never
|
||||
// reach a wire, which is the whole reason this is allowed; anywhere
|
||||
// else it is refused, in validation and again at dial time.
|
||||
MailSecurityNone = "none"
|
||||
)
|
||||
|
||||
// ValidMailSecurity reports whether s is a known security mode.
|
||||
// ValidMailSecurity reports whether s is a known security mode. It does not
|
||||
// say whether the mode is legal for a given host: MailSecurityNone also has
|
||||
// to pass LoopbackMailHost, which is the caller's job.
|
||||
func ValidMailSecurity(s string) bool {
|
||||
return s == MailSecurityTLS || s == MailSecurityStartTLS
|
||||
return s == MailSecurityTLS || s == MailSecurityStartTLS || s == MailSecurityNone
|
||||
}
|
||||
|
||||
// LoopbackMailHost reports whether host addresses this machine, the only
|
||||
// place MailSecurityNone is allowed.
|
||||
//
|
||||
// Literals only, deliberately: a name that resolves to 127.0.0.1 today can
|
||||
// resolve elsewhere at dial time, so accepting one here would be a check the
|
||||
// network could walk out from under. The dialer verifies the peer it actually
|
||||
// got as well, which is what closes that gap for good.
|
||||
func LoopbackMailHost(host string) bool {
|
||||
host = strings.TrimSpace(host)
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
// A bracketed IPv6 literal ("[::1]") is how a host:port string carries
|
||||
// one, and users paste it that way.
|
||||
host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveSMTPSecurity returns the security mode to dial SMTP with: the stored
|
||||
// choice when it is set, otherwise the conventional default for the port. The
|
||||
// fallback keeps mailboxes connected across the rollout, when the stored value
|
||||
// is empty and events from older workers carry no mode at all.
|
||||
//
|
||||
// MailSecurityNone is never inferred, only obeyed: dropping encryption is
|
||||
// always an explicit choice, never something a port number decides.
|
||||
func ResolveSMTPSecurity(security string, port int) string {
|
||||
if ValidMailSecurity(security) {
|
||||
return security
|
||||
@@ -180,7 +215,8 @@ func ResolveSMTPSecurity(security string, port int) string {
|
||||
}
|
||||
|
||||
// ResolveIMAPSecurity is ResolveSMTPSecurity for IMAP, where implicit TLS is
|
||||
// the norm (993) and 143 is the STARTTLS port.
|
||||
// the norm (993) and 143 is the STARTTLS port. Like the SMTP resolver it
|
||||
// never infers MailSecurityNone.
|
||||
func ResolveIMAPSecurity(security string, port int) string {
|
||||
if ValidMailSecurity(security) {
|
||||
return security
|
||||
|
||||
@@ -51,16 +51,54 @@ func TestResolveIMAPSecurity(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidMailSecurity(t *testing.T) {
|
||||
for _, s := range []string{MailSecurityTLS, MailSecurityStartTLS} {
|
||||
for _, s := range []string{MailSecurityTLS, MailSecurityStartTLS, MailSecurityNone} {
|
||||
if !ValidMailSecurity(s) {
|
||||
t.Fatalf("ValidMailSecurity(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
// "none" is deliberately not a mode: TLS is mandatory for mailboxes, and
|
||||
// the cleartext escape hatch is the instance-level MAIL_TLS_INSECURE knob.
|
||||
for _, s := range []string{"", "none", "ssl", "TLS"} {
|
||||
for _, s := range []string{"", "ssl", "TLS", "plain", "insecure"} {
|
||||
if ValidMailSecurity(s) {
|
||||
t.Fatalf("ValidMailSecurity(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "none" is the one mode that puts a password on an unencrypted socket, so it
|
||||
// is never reached by inference: only a stored, explicit choice produces it.
|
||||
func TestSecurityNoneIsNeverInferred(t *testing.T) {
|
||||
for _, port := range []int{25, 143, 465, 587, 993, 1025, 1143, 2525} {
|
||||
if got := ResolveSMTPSecurity("", port); got == MailSecurityNone {
|
||||
t.Errorf("ResolveSMTPSecurity(\"\", %d) inferred %q", port, got)
|
||||
}
|
||||
if got := ResolveIMAPSecurity("", port); got == MailSecurityNone {
|
||||
t.Errorf("ResolveIMAPSecurity(\"\", %d) inferred %q", port, got)
|
||||
}
|
||||
}
|
||||
// Stored, it is obeyed on any port: Proton Bridge listens on 1143/1025.
|
||||
if got := ResolveIMAPSecurity(MailSecurityNone, 1143); got != MailSecurityNone {
|
||||
t.Errorf("ResolveIMAPSecurity(none, 1143) = %q, want none", got)
|
||||
}
|
||||
if got := ResolveSMTPSecurity(MailSecurityNone, 1025); got != MailSecurityNone {
|
||||
t.Errorf("ResolveSMTPSecurity(none, 1025) = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The loopback rule is what makes the unencrypted mode safe, so it has to be
|
||||
// exact: a name that merely looks local is not local.
|
||||
func TestLoopbackMailHost(t *testing.T) {
|
||||
for _, h := range []string{"localhost", "LocalHost", " localhost ", "127.0.0.1", "127.0.1.5", "::1", "[::1]"} {
|
||||
if !LoopbackMailHost(h) {
|
||||
t.Errorf("LoopbackMailHost(%q) = false, want true", h)
|
||||
}
|
||||
}
|
||||
for _, h := range []string{
|
||||
"", "example.com", "0.0.0.0", "10.0.0.1", "192.168.1.10", "169.254.169.254",
|
||||
// Names that read as local but resolve wherever their owner points
|
||||
// them, which is the whole reason only literals are accepted.
|
||||
"localhost.example.com", "notlocalhost", "127.0.0.1.example.com",
|
||||
} {
|
||||
if LoopbackMailHost(h) {
|
||||
t.Errorf("LoopbackMailHost(%q) = true, want false", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2562,6 +2562,11 @@ finale() {
|
||||
out " ${DIM}Back up${R} docker compose -p warmbly exec backend warmblyctl backup"
|
||||
out " ${DIM}Update${R} the version pill in the admin panel, or compose pull + up -d"
|
||||
out " ${DIM}Guide${R} $DOCS/development/first-run/"
|
||||
out " ${DIM}Mailboxes${R} $DOCS/guides/mailboxes/"
|
||||
say ""
|
||||
note "A mail server on this machine, such as Proton Bridge on 127.0.0.1,"
|
||||
note "connects with Security: None. That mode is offered only here, where"
|
||||
note "the worker and the relay share a host and no password reaches a wire."
|
||||
say ""
|
||||
if [ "$EXISTING" = 0 ]; then
|
||||
out " ${RED}Keep a copy of $DIR/keys-backup.txt somewhere other than this machine.${R}"
|
||||
|
||||
@@ -1 +1 @@
|
||||
393db1b2d1b91afeceb20f8b5ad904b5c3377e19480f85da9d635871f6d262a1 install.sh
|
||||
e029f3a9c9de2f4f3bfb648018435a2da20eee4d0dabd4e1659d80afc4eb44d1 install.sh
|
||||
|
||||
@@ -1,42 +1,67 @@
|
||||
// Segmented picker for a mailbox leg's connection security. Shared by the
|
||||
// connect modal and the reconnect dialog so both describe the choice the same
|
||||
// way. Theme primitives only: h-7 control, slate border, sky active state.
|
||||
//
|
||||
// The unencrypted option is not a permanent third segment. It appears only
|
||||
// once the host is a loopback literal on a self-hosted instance, which is the
|
||||
// only shape the backend and the worker accept, so the form never offers a
|
||||
// mode that is going to be refused.
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MailSecurity } from "@/lib/api/models/app/emails/Service";
|
||||
import { allowsNoEncryption, type MailSecurity } from "@/lib/api/models/app/emails/Service";
|
||||
|
||||
const OPTIONS: { value: MailSecurity; label: string; hint: string }[] = [
|
||||
{ value: "tls", label: "SSL / TLS", hint: "Encrypted from the first byte (SMTP 465, IMAP 993)" },
|
||||
{ value: "starttls", label: "STARTTLS", hint: "Upgrades after connecting (SMTP 587 or 2525, IMAP 143)" },
|
||||
{ value: "none", label: "None", hint: "No encryption. Only to a mail server on this machine, such as Proton Bridge" },
|
||||
];
|
||||
|
||||
export default function SecuritySelect({
|
||||
value,
|
||||
onChange,
|
||||
host = "",
|
||||
selfHosted = false,
|
||||
}: {
|
||||
value: MailSecurity;
|
||||
onChange: (v: MailSecurity) => void;
|
||||
/** The leg's host, which decides whether "None" is offered at all. */
|
||||
host?: string;
|
||||
/** Hosted instances never run the worker on the customer's machine. */
|
||||
selfHosted?: boolean;
|
||||
}) {
|
||||
const allowNone = allowsNoEncryption(host, selfHosted);
|
||||
const options = OPTIONS.filter((o) => o.value !== "none" || allowNone);
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch h-7 rounded-md border border-slate-200 bg-white overflow-hidden">
|
||||
{OPTIONS.map((o, i) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
title={o.hint}
|
||||
aria-pressed={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 px-2 text-[12.5px] transition-colors",
|
||||
i > 0 && "border-l border-slate-200",
|
||||
value === o.value
|
||||
? "bg-sky-50 text-sky-700 font-medium"
|
||||
: "text-slate-600 hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
<div>
|
||||
<div className="flex items-stretch h-7 rounded-md border border-slate-200 bg-white overflow-hidden">
|
||||
{options.map((o, i) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
title={o.hint}
|
||||
aria-pressed={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 px-2 text-[12.5px] transition-colors",
|
||||
i > 0 && "border-l border-slate-200",
|
||||
value === o.value
|
||||
? o.value === "none"
|
||||
? "bg-amber-50 text-amber-700 font-medium"
|
||||
: "bg-sky-50 text-sky-700 font-medium"
|
||||
: "text-slate-600 hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{value === "none" && allowNone && (
|
||||
<p className="mt-1 text-[11.5px] leading-[1.4] text-slate-500">
|
||||
Credentials go over an unencrypted connection to this machine only. Warmbly refuses this mode for any
|
||||
other host.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import updateEmailCredentials from "@/lib/api/client/app/emails/updateEmailCredentials";
|
||||
import {
|
||||
allowsNoEncryption,
|
||||
defaultImapSecurity,
|
||||
defaultSmtpSecurity,
|
||||
validPort,
|
||||
type MailSecurity,
|
||||
} from "@/lib/api/models/app/emails/Service";
|
||||
import SecuritySelect from "@/components/app/emails/SecuritySelect";
|
||||
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
|
||||
|
||||
export default function UpdateCredentialsDialog({
|
||||
mailboxId,
|
||||
@@ -61,6 +63,21 @@ export default function UpdateCredentialsDialog({
|
||||
if (!smtpSecurityTouched.current) setSmtpSecurity(defaultSmtpSecurity(Number(smtpPort)));
|
||||
}, [smtpPort]);
|
||||
|
||||
// Same rule as the connect form: "no encryption" belongs to a loopback
|
||||
// host on a self-hosted instance, and editing the host away from one takes
|
||||
// the mode with it rather than leaving a value the backend refuses.
|
||||
const selfHosted = useAuthConfig().data?.self_hosted === true;
|
||||
React.useEffect(() => {
|
||||
if (imapSecurity === "none" && !allowsNoEncryption(imapHost, selfHosted)) {
|
||||
setImapSecurity(defaultImapSecurity(Number(imapPort)));
|
||||
}
|
||||
}, [imapHost, imapPort, imapSecurity, selfHosted]);
|
||||
React.useEffect(() => {
|
||||
if (smtpSecurity === "none" && !allowsNoEncryption(smtpHost, selfHosted)) {
|
||||
setSmtpSecurity(defaultSmtpSecurity(Number(smtpPort)));
|
||||
}
|
||||
}, [smtpHost, smtpPort, smtpSecurity, selfHosted]);
|
||||
|
||||
// Reset when reopened so a cancelled attempt never leaks a typed password.
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
@@ -173,6 +190,8 @@ export default function UpdateCredentialsDialog({
|
||||
<Field label="Security">
|
||||
<SecuritySelect
|
||||
value={imapSecurity}
|
||||
host={imapHost}
|
||||
selfHosted={selfHosted}
|
||||
onChange={(v) => {
|
||||
imapSecurityTouched.current = true;
|
||||
setImapSecurity(v);
|
||||
@@ -194,6 +213,8 @@ export default function UpdateCredentialsDialog({
|
||||
<Field label="Security">
|
||||
<SecuritySelect
|
||||
value={smtpSecurity}
|
||||
host={smtpHost}
|
||||
selfHosted={selfHosted}
|
||||
onChange={(v) => {
|
||||
smtpSecurityTouched.current = true;
|
||||
setSmtpSecurity(v);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type AddEmail from "@/lib/api/models/app/emails/AddEmail";
|
||||
import {
|
||||
defaultImapSecurity,
|
||||
defaultSmtpSecurity,
|
||||
isLoopbackHost,
|
||||
validPort,
|
||||
type MailSecurity,
|
||||
} from "@/lib/api/models/app/emails/Service";
|
||||
@@ -85,11 +86,16 @@ function normaliseHeader(h: string): string {
|
||||
return ALIASES[key] ?? key;
|
||||
}
|
||||
|
||||
function parseSecurity(raw: string | undefined, port: number, leg: "smtp" | "imap"): MailSecurity | null {
|
||||
// "none" is accepted only for a loopback host, matching the backend. The
|
||||
// self-hosted half of that rule is not knowable here, so the API answers it;
|
||||
// this catches the mistake a CSV actually makes, which is pasting the mode
|
||||
// onto a remote server.
|
||||
function parseSecurity(raw: string | undefined, port: number, host: string, leg: "smtp" | "imap"): MailSecurity | null {
|
||||
const v = (raw ?? "").trim().toLowerCase();
|
||||
if (v === "") return leg === "smtp" ? defaultSmtpSecurity(port) : defaultImapSecurity(port);
|
||||
if (v === "tls" || v === "ssl" || v === "ssl/tls" || v === "implicit") return "tls";
|
||||
if (v === "starttls" || v === "start_tls" || v === "start-tls") return "starttls";
|
||||
if (v === "none" || v === "plain" || v === "insecure") return isLoopbackHost(host) ? "none" : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -147,10 +153,10 @@ function buildRow(line: number, raw: Record<string, string>): BulkRow {
|
||||
if (!imapPass) return invalid("Missing imap_password (or a shared password column)");
|
||||
if (!smtpPass) return invalid("Missing smtp_password (or a shared password column)");
|
||||
|
||||
const smtpSecurity = parseSecurity(raw.smtp_security, smtpPort, "smtp");
|
||||
const imapSecurity = parseSecurity(raw.imap_security, imapPort, "imap");
|
||||
if (!smtpSecurity) return invalid("smtp_security must be tls or starttls");
|
||||
if (!imapSecurity) return invalid("imap_security must be tls or starttls");
|
||||
const smtpSecurity = parseSecurity(raw.smtp_security, smtpPort, smtpHost, "smtp");
|
||||
const imapSecurity = parseSecurity(raw.imap_security, imapPort, imapHost, "imap");
|
||||
if (!smtpSecurity) return invalid("smtp_security must be tls or starttls (none only for a server on this machine)");
|
||||
if (!imapSecurity) return invalid("imap_security must be tls or starttls (none only for a server on this machine)");
|
||||
|
||||
return {
|
||||
line,
|
||||
|
||||
@@ -50,12 +50,14 @@ import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import addEmail from "@/lib/api/client/app/emails/addEmail";
|
||||
import {
|
||||
allowsNoEncryption,
|
||||
defaultImapSecurity,
|
||||
defaultSmtpSecurity,
|
||||
validPort,
|
||||
type MailSecurity,
|
||||
} from "@/lib/api/models/app/emails/Service";
|
||||
import SecuritySelect from "@/components/app/emails/SecuritySelect";
|
||||
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
|
||||
import onboardOAuthStart from "@/lib/api/client/app/emails/onboardOAuthStart";
|
||||
import onboardOAuthFinish from "@/lib/api/client/app/emails/onboardOAuthFinish";
|
||||
import { capture } from "@/lib/productAnalytics";
|
||||
@@ -834,6 +836,22 @@ function SmtpImapPanel({ onDone, onError }: { onDone: () => void; onError: (e: u
|
||||
}
|
||||
}, [smtpPort]);
|
||||
|
||||
// "No encryption" is only offered for a local relay on a self-hosted
|
||||
// instance. Editing the host away from loopback has to take the mode with
|
||||
// it, or the form keeps a value the backend will reject and the user is
|
||||
// left reading an error about a control that is no longer on screen.
|
||||
const selfHosted = useAuthConfig().data?.self_hosted === true;
|
||||
React.useEffect(() => {
|
||||
if (imapSecurity === "none" && !allowsNoEncryption(imapHost, selfHosted)) {
|
||||
setImapSecurity(defaultImapSecurity(Number(imapPort)));
|
||||
}
|
||||
}, [imapHost, imapPort, imapSecurity, selfHosted]);
|
||||
React.useEffect(() => {
|
||||
if (smtpSecurity === "none" && !allowsNoEncryption(smtpHost, selfHosted)) {
|
||||
setSmtpSecurity(defaultSmtpSecurity(Number(smtpPort)));
|
||||
}
|
||||
}, [smtpHost, smtpPort, smtpSecurity, selfHosted]);
|
||||
|
||||
// Single-credentials toggle — covers the 90% case where IMAP and SMTP
|
||||
// share the same login. The user can flip it off and supply distinct
|
||||
// SMTP creds for legacy setups.
|
||||
@@ -930,6 +948,8 @@ function SmtpImapPanel({ onDone, onError }: { onDone: () => void; onError: (e: u
|
||||
<Field label="Security">
|
||||
<SecuritySelect
|
||||
value={imapSecurity}
|
||||
host={imapHost}
|
||||
selfHosted={selfHosted}
|
||||
onChange={(v) => {
|
||||
imapSecurityTouched.current = true;
|
||||
setImapSecurity(v);
|
||||
@@ -965,6 +985,8 @@ function SmtpImapPanel({ onDone, onError }: { onDone: () => void; onError: (e: u
|
||||
<Field label="Security">
|
||||
<SecuritySelect
|
||||
value={smtpSecurity}
|
||||
host={smtpHost}
|
||||
selfHosted={selfHosted}
|
||||
onChange={(v) => {
|
||||
smtpSecurityTouched.current = true;
|
||||
setSmtpSecurity(v);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Connection security for a mailbox leg. TLS is mandatory either way; the
|
||||
// mode says whether it is negotiated before the greeting (implicit) or
|
||||
// upgraded in-band after it (STARTTLS). Mirrors models.MailSecurity* in Go.
|
||||
export type MailSecurity = "tls" | "starttls";
|
||||
// Connection security for a mailbox leg. "tls" is negotiated before the
|
||||
// greeting, "starttls" upgrades in-band after it, and "none" is no encryption
|
||||
// at all, which is only legal against a loopback host on a self-hosted
|
||||
// instance. Mirrors models.MailSecurity* in Go.
|
||||
export type MailSecurity = "tls" | "starttls" | "none";
|
||||
|
||||
export default interface Service {
|
||||
username: string;
|
||||
@@ -26,3 +27,30 @@ export function defaultImapSecurity(port: number): MailSecurity {
|
||||
export function validPort(port: number): boolean {
|
||||
return Number.isInteger(port) && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a host addresses this machine, the only place `none` is allowed.
|
||||
*
|
||||
* Literals only, matching models.LoopbackMailHost in Go: a name that resolves
|
||||
* to 127.0.0.1 today can resolve elsewhere at dial time, so the backend and
|
||||
* the worker both refuse anything but a literal and the form must not offer
|
||||
* what they would refuse.
|
||||
*/
|
||||
export function isLoopbackHost(host: string): boolean {
|
||||
const h = host.trim().toLowerCase();
|
||||
if (h === "localhost") return true;
|
||||
const bare = h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
|
||||
if (/^127(\.\d{1,3}){3}$/.test(bare)) {
|
||||
return bare.split(".").every((o) => Number(o) <= 255);
|
||||
}
|
||||
return bare === "::1" || bare === "0:0:0:0:0:0:0:1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the unencrypted mode is offerable at all: a loopback host on a
|
||||
* self-hosted instance. On the hosted product the worker is never the
|
||||
* customer's machine, so there is no local relay for it to reach.
|
||||
*/
|
||||
export function allowsNoEncryption(host: string, selfHosted: boolean): boolean {
|
||||
return selfHosted && isLoopbackHost(host);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user