fix: JMAP integration (#1445)

masukomi (a.k.a. Kay Rhodes) created

## What?
The code to support JMAP authentication via API tokens was in place, it
just wasn't wired up correctly. This fixes that.


- JMAP protocol selection ended up getting routed to IMAP code
- no way to specify `token` for `account.AuthMethod`
- `hasBackendProvider` didn't account for/support `jmap`
- modified placeholder when `account.AuthMethod` is `token` to say `API
Token` instead of `Password / App Password`
- modified tip for `inputAuthMethod` when protocol is `jmap`

## Why?
Because I use Fastmail, want to use JMAP, and want to be able to read my
email.

JMAP authentication with Fastmail via API Tokens wasn't working for
multiple reasons. Fastmail is the largest (only?) JMAP supporting email
host and they require API Tokens.

I have manually confirmed that this authenticates just fine with my
Fastmail account via JMAP and the API token.

Change summary

backend/jmap/jmap.go |  2 +-
fetcher/dispatch.go  |  7 ++++---
tui/login.go         | 34 +++++++++++++++++++++++++++-------
3 files changed, 32 insertions(+), 11 deletions(-)

Detailed changes

backend/jmap/jmap.go 🔗

@@ -53,7 +53,7 @@ func New(account *config.Account) (*Provider, error) {
 		SessionEndpoint: account.JMAPEndpoint,
 	}
 
-	if account.AuthMethod == "oauth2" {
+	if account.AuthMethod == "oauth2" || account.AuthMethod == "token" || account.AuthMethod == "" {
 		client.WithAccessToken(account.Password)
 	} else {
 		client.WithBasicAuth(account.Email, account.Password)

fetcher/dispatch.go 🔗

@@ -2,15 +2,16 @@ package fetcher
 
 import (
 	"github.com/floatpane/matcha/backend"
+	_ "github.com/floatpane/matcha/backend/jmap"    // register jmap backend
 	_ "github.com/floatpane/matcha/backend/maildir" // register maildir backend
 	"github.com/floatpane/matcha/config"
 )
 
 // hasBackendProvider reports whether the account is served by a non-IMAP
-// backend (currently only "maildir") and should be routed through the
-// backend.Provider abstraction instead of the legacy IMAP code path.
+// backend (currently only "maildir" and "jmap") and should be routed through
+// the backend.Provider abstraction instead of the legacy IMAP code path.
 func hasBackendProvider(account *config.Account) bool {
-	return account != nil && account.Protocol == "maildir"
+	return account != nil && (account.Protocol == "maildir" || account.Protocol == "jmap")
 }
 
 // newBackendProvider builds the backend.Provider for the account. Callers

tui/login.go 🔗

@@ -94,7 +94,7 @@ func NewLogin(hideTips bool) *Login {
 			t.Placeholder = "Send As Email (optional From header override)"
 			t.Prompt = "✉️ > "
 		case inputAuthMethod:
-			t.Placeholder = "Auth Method (password or oauth2)"
+			t.Placeholder = "Auth Method (password, oauth2, or token)"
 			t.Prompt = "🔐 > "
 		case inputPassword:
 			t.Placeholder = "Password / App Password"
@@ -178,7 +178,7 @@ func (m *Login) visibleFields() []int {
 	switch proto {
 	case protocolJMAP:
 		// JMAP: no provider selector, just endpoint + common fields
-		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputPassword, inputJMAPEndpoint)
+		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputAuthMethod, inputPassword, inputJMAPEndpoint)
 	case protocolPOP3:
 		// POP3: custom server fields + SMTP for sending
 		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputPassword,
@@ -313,6 +313,13 @@ func (m *Login) updateFlags() {
 	provider := m.inputs[inputProvider].Value()
 	m.showCustom = provider == "custom"
 	m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
+
+	authMethod := m.inputs[inputAuthMethod].Value()
+	if m.protocol() == protocolJMAP && (authMethod == "token" || authMethod == "") {
+		m.inputs[inputPassword].Placeholder = "API Token"
+	} else {
+		m.inputs[inputPassword].Placeholder = "Password / App Password"
+	}
 }
 
 // validPort parses a port string and returns the integer value if it is within
@@ -335,13 +342,21 @@ func (m *Login) submitForm() func() tea.Msg {
 	smtpPort := validPort(m.inputs[inputSMTPPort].Value(), 587)
 	pop3Port := validPort(m.inputs[inputPOP3Port].Value(), 995)
 
-	authMethod := "password"
-	if m.useOAuth2 {
+	proto := m.protocol()
+
+	var authMethod string
+	switch {
+	case proto == protocolJMAP:
+		authMethod = m.inputs[inputAuthMethod].Value()
+		if authMethod == "" {
+			authMethod = "token"
+		}
+	case m.useOAuth2:
 		authMethod = "oauth2"
+	default:
+		authMethod = "password"
 	}
 
-	proto := m.protocol()
-
 	insecure := m.inputs[inputInsecure].Value() == "true"
 	catchAll := m.inputs[inputCatchAll].Value() == "true"
 
@@ -414,6 +429,7 @@ func (m *Login) protocolFieldViews(proto string) []string {
 	switch proto {
 	case protocolJMAP:
 		return append(common,
+			m.inputs[inputAuthMethod].View(),
 			m.inputs[inputPassword].View(),
 			"",
 			listHeader.Render("JMAP Settings:"),
@@ -500,7 +516,11 @@ func (m *Login) View() tea.View {
 	case inputSendAsEmail:
 		tip = "Optional From header override for outgoing email. Leave blank to send as the fetched address."
 	case inputAuthMethod:
-		tip = "Type 'oauth2' for OAuth2 or 'password' for app password."
+		if m.protocol() == protocolJMAP {
+			tip = "Type 'token' for API token (Bearer auth, default) or 'password' for HTTP Basic auth."
+		} else {
+			tip = "Type 'oauth2' for OAuth2 or 'password' for app password."
+		}
 	case inputPassword:
 		tip = "Your password or an app-specific password if using 2FA."
 	case inputIMAPServer: