Merge branch 'main' into fix/issue-410-scanner-networks

This commit is contained in:
Matthew Meszaros
2026-09-10 04:32:36 -07:00
7 changed files with 112 additions and 14 deletions
@@ -508,7 +508,7 @@ https://api.example.com/addresses/outlook/callback
</Step>
<Step>
Under **API permissions**, add the Microsoft Graph delegated permissions the mailbox needs: `Mail.ReadWrite`, `Mail.Send`, `IMAP.AccessAsUser.All`, `SMTP.Send`, and `offline_access` so refresh tokens are issued.
Under **API permissions**, add the Microsoft Graph **delegated** permissions the mailbox needs, and no others: `User.Read`, `Mail.Send`, `Mail.ReadWrite`, and `offline_access` so refresh tokens are issued. Graph is the transport for both send and sync, so the legacy `IMAP.AccessAsUser.All` and `SMTP.Send` permissions are not requested and should not be added: each one is admin-consent-only and adding it turns a sign-in every user could complete into one only a tenant admin can.
</Step>
<Step>
@@ -522,6 +522,10 @@ BOX_OUTLOOK_CLIENT_SECRET=your-client-secret-value
</Steps>
<Callout title="Non-admin users and tenant-wide consent">
None of the four permissions above requires admin consent by default, so an ordinary user can connect their own mailbox. A tenant that has turned user consent off needs an admin to grant consent once, under **Enterprise applications > your app > Permissions > Grant admin consent**. Warmbly's authorize request asks for `prompt=select_account`, never `prompt=consent`, so that grant is honoured for everyone afterwards. See [approval required when connecting an Outlook mailbox](/development/troubleshooting/#the-stack-is-up-but-something-is-wrong) if a non-admin is still refused.
</Callout>
<Callout type="warn" title="Workers need these too">
The backend starts the OAuth flow, but **each worker refreshes the token** when it expires. Compose passes the `BOX_*` values to the worker automatically, and remote workers receive them in their enrollment config. If a worker is missing them, the mailbox connects fine and then silently stops about an hour later when its first access token expires.
</Callout>
@@ -79,6 +79,7 @@ Newer builds return the invite-only refusal with its own machine code, `registra
| 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 |
| Connecting an Outlook mailbox stops at "Approval required" with `AADSTS90095` | The tenant needs an admin to consent once, or the app registration asks for a permission it should not. Grant consent under **Enterprise applications > your app > Permissions > Grant admin consent**, and check the registration lists only the delegated `User.Read`, `Mail.Send`, `Mail.ReadWrite` and `offline_access`: `IMAP.AccessAsUser.All` and `SMTP.Send` are admin-consent-only, are not used, and refuse every non-admin on their own. If a non-admin is still refused after a tenant-wide grant, the instance predates the fix that stopped sending `prompt=consent`, which made Entra ID re-check consent eligibility per sign-in instead of honouring the grant; upgrade it |
| 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 |
+1 -1
View File
@@ -20,7 +20,7 @@ func (s *emailService) OAuthAuthorizeURL(provider models.InboxProvider, state st
if xerr != nil {
return "", xerr
}
return cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce), nil
return cfg.AuthCodeURL(state, authCodeOptions(provider, "")...), nil
}
func (s *emailService) OAuthConnectWithCode(ctx context.Context, userID string, orgID *uuid.UUID, provider models.InboxProvider, code string) (*models.Email, *errx.Error) {
+35
View File
@@ -0,0 +1,35 @@
package email
import (
"github.com/warmbly/warmbly/internal/models"
"golang.org/x/oauth2"
)
// authCodeOptions returns the authorization-request parameters for a provider.
// loginHint preselects a mailbox on a reconnect and is empty on a first connect.
//
// The two providers disagree about what it takes to be issued a refresh token,
// and asking for the wrong one is not free:
//
// - Google issues one only when access_type=offline is set, and re-issues one
// on a repeat authorization only when the consent screen is forced.
// - Microsoft issues one off the offline_access scope alone, so prompt=consent
// buys nothing there and costs a lot: Entra ID re-runs the consent
// eligibility check on every sign-in instead of honouring the grant already
// on the tenant, and refuses every non-admin with AADSTS90095 even when
// tenant-wide admin consent was granted (issue #409). prompt=select_account
// keeps the account picker, which is what stops a signed-in browser
// silently connecting the wrong mailbox, without that check.
func authCodeOptions(provider models.InboxProvider, loginHint string) []oauth2.AuthCodeOption {
var opts []oauth2.AuthCodeOption
if provider == models.InboxProviderOutlook {
opts = append(opts, oauth2.SetAuthURLParam("prompt", "select_account"))
} else {
opts = append(opts, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
}
if loginHint != "" {
// Preselect the mailbox being renewed in the provider's picker.
opts = append(opts, oauth2.SetAuthURLParam("login_hint", loginHint))
}
return opts
}
+68
View File
@@ -0,0 +1,68 @@
package email
import (
"net/url"
"testing"
"github.com/warmbly/warmbly/internal/models"
"golang.org/x/oauth2"
)
func authQuery(t *testing.T, provider models.InboxProvider, loginHint string) url.Values {
t.Helper()
cfg := &oauth2.Config{
ClientID: "client",
RedirectURL: "https://api.example.com/addresses/callback",
Endpoint: oauth2.Endpoint{AuthURL: "https://provider.example.com/authorize"},
}
raw := cfg.AuthCodeURL("state-value", authCodeOptions(provider, loginHint)...)
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse authorize url: %v", err)
}
return u.Query()
}
// prompt=consent makes Entra ID re-run the consent eligibility check for the
// signing user instead of honouring the tenant's existing admin grant, so every
// non-admin is refused with AADSTS90095 no matter how the grant is built
// (issue #409). Microsoft issues the refresh token off the offline_access scope,
// so nothing is lost by dropping it.
func TestAuthCodeOptions_OutlookNeverForcesConsent(t *testing.T) {
q := authQuery(t, models.InboxProviderOutlook, "")
if got := q.Get("prompt"); got != "select_account" {
t.Errorf("prompt = %q, want select_account so a tenant-wide admin grant is honoured", got)
}
if q.Has("access_type") {
t.Errorf("access_type = %q, a Google-only parameter Microsoft has no use for", q.Get("access_type"))
}
}
// Google re-issues a refresh token only when the consent screen is forced, and
// issues one at all only under access_type=offline. Losing either turns a
// reconnect into a mailbox that stops sending an hour later.
func TestAuthCodeOptions_GoogleKeepsOfflineConsent(t *testing.T) {
q := authQuery(t, models.InboxProviderGoogle, "")
if got := q.Get("prompt"); got != "consent" {
t.Errorf("prompt = %q, want consent so a repeat authorization still returns a refresh token", got)
}
if got := q.Get("access_type"); got != "offline" {
t.Errorf("access_type = %q, want offline", got)
}
}
// A reconnect names the mailbox it is renewing so the picker offers that
// account first; the finish leg still refuses tokens for any other address.
func TestAuthCodeOptions_LoginHintOnlyOnReconnect(t *testing.T) {
for _, provider := range []models.InboxProvider{models.InboxProviderGoogle, models.InboxProviderOutlook} {
if q := authQuery(t, provider, ""); q.Has("login_hint") {
t.Errorf("%s: a first connect must not preselect an account, got login_hint=%q", provider, q.Get("login_hint"))
}
q := authQuery(t, provider, "owner@example.com")
if got := q.Get("login_hint"); got != "owner@example.com" {
t.Errorf("%s: login_hint = %q, want the mailbox being renewed", provider, got)
}
}
}
+1 -5
View File
@@ -48,11 +48,7 @@ func (s *emailService) OAuthStart(ctx context.Context, userID string, orgID *uui
return nil, xerr
}
url := cfg.AuthCodeURL(
state,
oauth2.AccessTypeOffline,
oauth2.ApprovalForce, // force refresh_token issuance on reconnect
)
url := cfg.AuthCodeURL(state, authCodeOptions(provider, "")...)
return &models.EmailOnboardingStartResponse{URL: url, State: state}, nil
}
+1 -7
View File
@@ -68,13 +68,7 @@ func (s *emailService) OAuthReauth(ctx context.Context, userID string, orgID *uu
return nil, xerr
}
url := cfg.AuthCodeURL(
state,
oauth2.AccessTypeOffline,
oauth2.ApprovalForce, // force refresh_token issuance on reconnect
// Preselect the mailbox being renewed in the provider's picker.
oauth2.SetAuthURLParam("login_hint", account.Email),
)
url := cfg.AuthCodeURL(state, authCodeOptions(provider, account.Email)...)
return &models.EmailOnboardingStartResponse{URL: url, State: state}, nil
}