login.go

  1package tui
  2
  3import (
  4	"strconv"
  5	"strings"
  6
  7	"charm.land/bubbles/v2/textinput"
  8	tea "charm.land/bubbletea/v2"
  9	"charm.land/lipgloss/v2"
 10	"github.com/floatpane/matcha/theme"
 11)
 12
 13// Supported account protocols.
 14const (
 15	protocolIMAP    = "imap"
 16	protocolJMAP    = "jmap"
 17	protocolPOP3    = "pop3"
 18	protocolMaildir = "maildir"
 19)
 20
 21// loginProtocols are the selectable protocols shown in the protocol combobox,
 22// in cycle order.
 23var loginProtocols = []string{protocolIMAP, protocolJMAP, protocolPOP3, protocolMaildir}
 24
 25// Login holds the state for the login/add account form.
 26type Login struct {
 27	focusIndex int
 28	inputs     []textinput.Model
 29	showCustom bool // Show custom server fields
 30	useOAuth2  bool // Use OAuth2 instead of password (for gmail)
 31	isEditMode bool // Whether we're editing an existing account
 32	accountID  string
 33	hideTips   bool
 34	width      int
 35	height     int
 36}
 37
 38const (
 39	inputProtocol = iota // "imap", "jmap", or "pop3"
 40	inputProvider        // "gmail", "icloud", or "custom"
 41	inputName
 42	inputEmail
 43	inputFetchEmail
 44	inputSendAsEmail
 45	inputAuthMethod // "password" or "oauth2" (shown for gmail)
 46	inputPassword
 47	inputIMAPServer
 48	inputIMAPPort
 49	inputSMTPServer
 50	inputSMTPPort
 51	inputInsecure
 52	inputCatchAll     // "true/false" — show all inbox messages regardless of To address
 53	inputJMAPEndpoint // JMAP session URL
 54	inputPOP3Server
 55	inputPOP3Port
 56	inputMaildirPath // Local Maildir root path
 57	inputCount
 58)
 59
 60// NewLogin creates a new login model for adding accounts.
 61func NewLogin(hideTips bool) *Login {
 62	m := &Login{
 63		inputs:   make([]textinput.Model, inputCount),
 64		hideTips: hideTips,
 65	}
 66
 67	tiStyles := ThemedTextInputStyles()
 68	var t textinput.Model
 69	for i := range m.inputs {
 70		t = textinput.New()
 71		t.CharLimit = 128
 72		t.SetStyles(tiStyles)
 73
 74		switch i {
 75		case inputProtocol:
 76			// Rendered as a combobox (see viewProtocolCombobox); the textinput
 77			// is only used to hold the selected value. Seed a default so a
 78			// protocol is always selected.
 79			t.SetValue(loginProtocols[0])
 80			t.Prompt = "🌐 > "
 81		case inputProvider:
 82			t.Placeholder = "Provider (gmail, outlook, icloud, or custom)"
 83			t.Prompt = "🏢 > "
 84		case inputName:
 85			t.Placeholder = "Display Name"
 86			t.Prompt = "👤 > "
 87		case inputEmail:
 88			t.Placeholder = "Username"
 89			t.Prompt = "🏠 > "
 90		case inputFetchEmail:
 91			t.Placeholder = "Email Address (comma-separated for multiple)"
 92			t.Prompt = "📧 > "
 93		case inputSendAsEmail:
 94			t.Placeholder = "Send As Email (optional From header override)"
 95			t.Prompt = "✉️ > "
 96		case inputAuthMethod:
 97			t.Placeholder = "Auth Method (password, oauth2, or token)"
 98			t.Prompt = "🔐 > "
 99		case inputPassword:
100			t.Placeholder = "Password / App Password"
101			t.EchoMode = textinput.EchoPassword
102			t.Prompt = "🔑 > "
103		case inputIMAPServer:
104			t.Placeholder = "IMAP Server (e.g., imap.example.com)"
105			t.Prompt = "📥 > "
106		case inputIMAPPort:
107			t.Placeholder = "IMAP Port (default: 993)"
108			t.Prompt = "🔢 > "
109		case inputSMTPServer:
110			t.Placeholder = "SMTP Server (e.g., smtp.example.com)"
111			t.Prompt = "📤 > "
112		case inputSMTPPort:
113			t.Placeholder = "SMTP Port (default: 587)"
114			t.Prompt = "🔢 > "
115		case inputInsecure:
116			t.Placeholder = "Insecure (true/false) - Skip TLS verification"
117			t.Prompt = "🔓 > "
118		case inputCatchAll:
119			t.Placeholder = "Catch-All (true/false) - Show all inbox messages"
120			t.Prompt = "📬 > "
121		case inputJMAPEndpoint:
122			t.Placeholder = "JMAP Session URL (e.g., https://api.fastmail.com/jmap/session)"
123			t.Prompt = "🔗 > "
124		case inputPOP3Server:
125			t.Placeholder = "POP3 Server (e.g., pop.example.com)"
126			t.Prompt = "📥 > "
127		case inputPOP3Port:
128			t.Placeholder = "POP3 Port (default: 995)"
129			t.Prompt = "🔢 > "
130		case inputMaildirPath:
131			t.Placeholder = "Maildir Path (e.g., ~/Mail or /var/mail/user)"
132			t.Prompt = "📁 > "
133		}
134		m.inputs[i] = t
135	}
136
137	return m
138}
139
140// Init initializes the login model.
141func (m *Login) Init() tea.Cmd {
142	return textinput.Blink
143}
144
145// protocol returns the currently selected protocol (defaults to "imap").
146func (m *Login) protocol() string {
147	p := m.inputs[inputProtocol].Value()
148	if p == "" {
149		return protocolIMAP
150	}
151	return p
152}
153
154// cycleProtocol moves the protocol selection by delta (+1 next, -1 previous),
155// wrapping around the loginProtocols list.
156func (m *Login) cycleProtocol(delta int) {
157	cur := m.protocol()
158	idx := 0
159	for i, p := range loginProtocols {
160		if p == cur {
161			idx = i
162			break
163		}
164	}
165	idx = (idx + delta + len(loginProtocols)) % len(loginProtocols)
166	m.inputs[inputProtocol].SetValue(loginProtocols[idx])
167}
168
169// visibleFields returns the ordered list of input indices the user should see
170// for the current protocol/provider/auth combination.
171func (m *Login) visibleFields() []int {
172	proto := m.protocol()
173	provider := m.inputs[inputProvider].Value()
174	hasOAuth := provider == "gmail" || provider == "outlook"
175
176	fields := []int{inputProtocol}
177
178	switch proto {
179	case protocolJMAP:
180		// JMAP: no provider selector, just endpoint + common fields
181		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputAuthMethod, inputPassword, inputJMAPEndpoint)
182	case protocolPOP3:
183		// POP3: custom server fields + SMTP for sending
184		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputPassword,
185			inputPOP3Server, inputPOP3Port, inputSMTPServer, inputSMTPPort, inputInsecure)
186	case protocolMaildir:
187		// Maildir: local filesystem only — no auth, no network.
188		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputMaildirPath)
189	default:
190		// IMAP (default): existing flow
191		fields = append(fields, inputProvider, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll)
192		if hasOAuth {
193			fields = append(fields, inputAuthMethod)
194		}
195		if !m.useOAuth2 {
196			fields = append(fields, inputPassword)
197		}
198		if m.showCustom {
199			fields = append(fields, inputIMAPServer, inputIMAPPort, inputSMTPServer, inputSMTPPort, inputInsecure)
200		}
201	}
202
203	return fields
204}
205
206// Update handles messages for the login model.
207func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
208	switch msg := msg.(type) {
209	case tea.WindowSizeMsg:
210		m.width = msg.Width
211		m.height = msg.Height
212		for i := range m.inputs {
213			m.inputs[i].SetWidth(msg.Width - 6)
214		}
215
216	case tea.KeyPressMsg:
217		switch msg.String() {
218		case "esc":
219			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
220
221		case keyLeft, keyRight, "h", "l", "space":
222			// On the protocol combobox, left/right (or h/l, space) cycle the
223			// selection instead of editing text. Elsewhere, fall through so the
224			// focused textinput handles the key normally.
225			if m.focusIndex == inputProtocol {
226				if msg.String() == keyLeft || msg.String() == "h" {
227					m.cycleProtocol(-1)
228				} else {
229					m.cycleProtocol(1)
230				}
231				return m, nil
232			}
233
234		case "ctrl+v":
235			// Toggle password visibility while focused on the password field,
236			// so typos in app-passwords are catchable without retyping.
237			if m.focusIndex == inputPassword {
238				if m.inputs[inputPassword].EchoMode == textinput.EchoPassword {
239					m.inputs[inputPassword].EchoMode = textinput.EchoNormal
240				} else {
241					m.inputs[inputPassword].EchoMode = textinput.EchoPassword
242				}
243				return m, nil
244			}
245
246		case keyEnter:
247			m.updateFlags()
248			visible := m.visibleFields()
249			lastField := visible[len(visible)-1]
250
251			if m.focusIndex == lastField {
252				return m, m.submitForm()
253			}
254			fallthrough
255
256		case "tab", keyShiftTab, "up", keyDown:
257			s := msg.String()
258			m.updateFlags()
259			visible := m.visibleFields()
260
261			// Find current position in visible fields
262			curPos := 0
263			for i, f := range visible {
264				if f == m.focusIndex {
265					curPos = i
266					break
267				}
268			}
269
270			if s == "up" || s == keyShiftTab {
271				curPos--
272			} else {
273				curPos++
274			}
275
276			if curPos >= len(visible) {
277				curPos = 0
278			} else if curPos < 0 {
279				curPos = len(visible) - 1
280			}
281
282			m.focusIndex = visible[curPos]
283
284			cmds := make([]tea.Cmd, len(m.inputs))
285			for i := 0; i < len(m.inputs); i++ {
286				if i == m.focusIndex {
287					cmds[i] = m.inputs[i].Focus()
288				} else {
289					m.inputs[i].Blur()
290				}
291			}
292			return m, tea.Batch(cmds...)
293		}
294	}
295
296	// Update the focused input field. The protocol field is a combobox, not a
297	// text field, so it never consumes raw input here.
298	var cmds = make([]tea.Cmd, len(m.inputs))
299	for i := range m.inputs {
300		if i == inputProtocol {
301			continue
302		}
303		m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
304	}
305
306	m.updateFlags()
307
308	return m, tea.Batch(cmds...)
309}
310
311// updateFlags recalculates showCustom and useOAuth2 from current inputs.
312func (m *Login) updateFlags() {
313	provider := m.inputs[inputProvider].Value()
314	m.showCustom = provider == "custom"
315	m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
316
317	authMethod := m.inputs[inputAuthMethod].Value()
318	if m.protocol() == protocolJMAP && (authMethod == "token" || authMethod == "") {
319		m.inputs[inputPassword].Placeholder = "API Token"
320	} else {
321		m.inputs[inputPassword].Placeholder = "Password / App Password"
322	}
323}
324
325// validPort parses a port string and returns the integer value if it is within
326// the valid TCP/UDP port range (1-65535). Returns the fallback if the string is
327// empty or invalid.
328func validPort(s string, fallback int) int {
329	if s == "" {
330		return fallback
331	}
332	p, err := strconv.Atoi(s)
333	if err != nil || p < 1 || p > 65535 {
334		return fallback
335	}
336	return p
337}
338
339// submitForm builds and returns a Credentials message from the current inputs.
340func (m *Login) submitForm() func() tea.Msg {
341	imapPort := validPort(m.inputs[inputIMAPPort].Value(), 993)
342	smtpPort := validPort(m.inputs[inputSMTPPort].Value(), 587)
343	pop3Port := validPort(m.inputs[inputPOP3Port].Value(), 995)
344
345	proto := m.protocol()
346
347	var authMethod string
348	switch {
349	case proto == protocolJMAP:
350		authMethod = m.inputs[inputAuthMethod].Value()
351		if authMethod == "" {
352			authMethod = "token"
353		}
354	case m.useOAuth2:
355		authMethod = "oauth2"
356	default:
357		authMethod = "password"
358	}
359
360	insecure := m.inputs[inputInsecure].Value() == "true"
361	catchAll := m.inputs[inputCatchAll].Value() == "true"
362
363	return func() tea.Msg {
364		return Credentials{
365			Protocol:     proto,
366			Provider:     m.inputs[inputProvider].Value(),
367			Name:         m.inputs[inputName].Value(),
368			Host:         m.inputs[inputEmail].Value(),
369			FetchEmail:   m.inputs[inputFetchEmail].Value(),
370			SendAsEmail:  m.inputs[inputSendAsEmail].Value(),
371			CatchAll:     catchAll,
372			Password:     m.inputs[inputPassword].Value(),
373			IMAPServer:   m.inputs[inputIMAPServer].Value(),
374			IMAPPort:     imapPort,
375			SMTPServer:   m.inputs[inputSMTPServer].Value(),
376			SMTPPort:     smtpPort,
377			Insecure:     insecure,
378			AuthMethod:   authMethod,
379			JMAPEndpoint: m.inputs[inputJMAPEndpoint].Value(),
380			POP3Server:   m.inputs[inputPOP3Server].Value(),
381			POP3Port:     pop3Port,
382			MaildirPath:  m.inputs[inputMaildirPath].Value(),
383		}
384	}
385}
386
387// viewProtocolCombobox renders the protocol selector as a segmented combobox,
388// highlighting the current selection and dimming the alternatives.
389func (m *Login) viewProtocolCombobox() string {
390	th := theme.ActiveTheme
391	focused := m.focusIndex == inputProtocol
392	cur := m.protocol()
393
394	promptColor := th.MutedText
395	if focused {
396		promptColor = th.AccentText
397	}
398	prompt := lipgloss.NewStyle().Foreground(promptColor).Render("🌐 > ")
399
400	selStyle := lipgloss.NewStyle().Foreground(th.Accent).Bold(true)
401	optStyle := lipgloss.NewStyle().Foreground(th.DimText)
402
403	parts := make([]string, len(loginProtocols))
404	for i, p := range loginProtocols {
405		switch {
406		case p == cur && focused:
407			parts[i] = selStyle.Render("‹ " + p + " ›")
408		case p == cur:
409			parts[i] = selStyle.Render("  " + p + "  ")
410		default:
411			parts[i] = optStyle.Render("  " + p + "  ")
412		}
413	}
414
415	return prompt + strings.Join(parts, "")
416}
417
418// protocolFieldViews returns the rendered input fields specific to the given
419// protocol, in display order.
420func (m *Login) protocolFieldViews(proto string) []string {
421	common := []string{
422		m.inputs[inputName].View(),
423		m.inputs[inputEmail].View(),
424		m.inputs[inputFetchEmail].View(),
425		m.inputs[inputSendAsEmail].View(),
426		m.inputs[inputCatchAll].View(),
427	}
428
429	switch proto {
430	case protocolJMAP:
431		return append(common,
432			m.inputs[inputAuthMethod].View(),
433			m.inputs[inputPassword].View(),
434			"",
435			listHeader.Render("JMAP Settings:"),
436			m.inputs[inputJMAPEndpoint].View(),
437		)
438	case protocolPOP3:
439		return append(common,
440			m.inputs[inputPassword].View(),
441			"",
442			listHeader.Render("POP3 Server Settings:"),
443			m.inputs[inputPOP3Server].View(),
444			m.inputs[inputPOP3Port].View(),
445			"",
446			listHeader.Render("SMTP Settings (for sending):"),
447			m.inputs[inputSMTPServer].View(),
448			m.inputs[inputSMTPPort].View(),
449			m.inputs[inputInsecure].View(),
450		)
451	case protocolMaildir:
452		return append(common,
453			"",
454			listHeader.Render("Maildir Settings:"),
455			m.inputs[inputMaildirPath].View(),
456		)
457	default:
458		return m.imapFieldViews(common)
459	}
460}
461
462// imapFieldViews renders the IMAP-specific fields (provider selector, optional
463// OAuth2/password, and custom server settings) appended after the common fields.
464func (m *Login) imapFieldViews(common []string) []string {
465	provider := m.inputs[inputProvider].Value()
466	hasOAuth := provider == "gmail" || provider == "outlook"
467
468	views := append([]string{m.inputs[inputProvider].View()}, common...)
469
470	if hasOAuth {
471		views = append(views, m.inputs[inputAuthMethod].View())
472	}
473
474	if !m.useOAuth2 {
475		views = append(views, m.inputs[inputPassword].View())
476	} else {
477		views = append(views, accountEmailStyle.Render("OAuth2 selected — browser authorization will open after submit"))
478	}
479
480	if m.showCustom {
481		views = append(views,
482			"",
483			accountEmailStyle.Render("Custom provider selected - configure server settings below"),
484			m.inputs[inputIMAPServer].View(),
485			m.inputs[inputIMAPPort].View(),
486			m.inputs[inputSMTPServer].View(),
487			m.inputs[inputSMTPPort].View(),
488			m.inputs[inputInsecure].View(),
489		)
490	}
491
492	return views
493}
494
495// View renders the login form.
496func (m *Login) View() tea.View {
497	title := "Add Account"
498	if m.isEditMode {
499		title = "Edit Account"
500	}
501
502	proto := m.protocol()
503
504	tip := ""
505	switch m.focusIndex {
506	case inputProtocol:
507		tip = "Use ←/→ to choose the protocol: imap (default), jmap, pop3, or maildir."
508	case inputProvider:
509		tip = "Enter your email provider (e.g., gmail, outlook, icloud) or 'custom'."
510	case inputName:
511		tip = "The name that will appear on emails you send."
512	case inputEmail:
513		tip = "Your full email address used to log in."
514	case inputFetchEmail:
515		tip = "The email address to fetch messages for (comma-separated for multiple, e.g. me@icloud.com,me@mac.com)."
516	case inputSendAsEmail:
517		tip = "Optional From header override for outgoing email. Leave blank to send as the fetched address."
518	case inputAuthMethod:
519		if m.protocol() == protocolJMAP {
520			tip = "Type 'token' for API token (Bearer auth, default) or 'password' for HTTP Basic auth."
521		} else {
522			tip = "Type 'oauth2' for OAuth2 or 'password' for app password."
523		}
524	case inputPassword:
525		tip = "Your password or an app-specific password if using 2FA."
526	case inputIMAPServer:
527		tip = "The server address for receiving emails."
528	case inputIMAPPort:
529		tip = "The port for the IMAP server (usually 993 for SSL)."
530	case inputSMTPServer:
531		tip = "The server address for sending emails."
532	case inputSMTPPort:
533		tip = "The port for the SMTP server (usually 587 for TLS)."
534	case inputInsecure:
535		tip = "Type 'true' to disable TLS certificate verification (not recommended)."
536	case inputCatchAll:
537		tip = "Type 'true' to show all inbox messages regardless of To address (useful for catch-all domains)."
538	case inputJMAPEndpoint:
539		tip = "The JMAP session resource URL (e.g., https://api.fastmail.com/jmap/session)."
540	case inputPOP3Server:
541		tip = "The POP3 server address for receiving emails."
542	case inputPOP3Port:
543		tip = "The port for the POP3 server (usually 995 for SSL)."
544	case inputMaildirPath:
545		tip = "Local path to a Maildir directory (cur/new/tmp). Subfolders use .Foldername (Maildir++)."
546	}
547
548	views := []string{
549		titleStyle.Render(title),
550		"Enter your email account credentials.",
551		"",
552		m.viewProtocolCombobox(),
553	}
554
555	views = append(views, m.protocolFieldViews(proto)...)
556
557	views = append(views, "")
558	if !m.hideTips && tip != "" {
559		views = append(views, TipStyle.Render("Tip: "+tip))
560	}
561	helpLine := "enter: save • tab: next field • esc: back to menu"
562	if m.focusIndex == inputProtocol {
563		helpLine += " • ←/→: change protocol"
564	}
565	if m.focusIndex == inputPassword {
566		helpLine += " • ctrl+v: toggle password visibility"
567	}
568	views = append(views, helpStyle.Render("\n"+helpLine))
569
570	return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, views...))
571}
572
573// SetEditMode sets the login form to edit an existing account.
574func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, sendAsEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, insecure bool, jmapEndpoint, pop3Server string, pop3Port int, catchAll bool, maildirPath string) {
575	m.isEditMode = true
576	m.accountID = accountID
577
578	if protocol == "" {
579		protocol = protocolIMAP
580	}
581	m.inputs[inputProtocol].SetValue(protocol)
582	m.inputs[inputProvider].SetValue(provider)
583	m.inputs[inputName].SetValue(name)
584	m.inputs[inputEmail].SetValue(email)
585	m.inputs[inputFetchEmail].SetValue(fetchEmail)
586	m.inputs[inputSendAsEmail].SetValue(sendAsEmail)
587	if catchAll {
588		m.inputs[inputCatchAll].SetValue("true")
589	} else {
590		m.inputs[inputCatchAll].SetValue("false")
591	}
592	m.showCustom = provider == "custom"
593
594	if m.showCustom {
595		m.inputs[inputIMAPServer].SetValue(imapServer)
596		if insecure {
597			m.inputs[inputInsecure].SetValue("true")
598		} else {
599			m.inputs[inputInsecure].SetValue("false")
600		}
601		if imapPort != 0 {
602			m.inputs[inputIMAPPort].SetValue(strconv.Itoa(imapPort))
603		}
604		m.inputs[inputSMTPServer].SetValue(smtpServer)
605		if smtpPort != 0 {
606			m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort))
607		}
608	}
609
610	if jmapEndpoint != "" {
611		m.inputs[inputJMAPEndpoint].SetValue(jmapEndpoint)
612	}
613	if pop3Server != "" {
614		m.inputs[inputPOP3Server].SetValue(pop3Server)
615	}
616	if pop3Port != 0 {
617		m.inputs[inputPOP3Port].SetValue(strconv.Itoa(pop3Port))
618	}
619	// Also set SMTP for POP3
620	if protocol == protocolPOP3 {
621		m.inputs[inputSMTPServer].SetValue(smtpServer)
622		if smtpPort != 0 {
623			m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort))
624		}
625	}
626	if maildirPath != "" {
627		m.inputs[inputMaildirPath].SetValue(maildirPath)
628	}
629}
630
631// GetAccountID returns the account ID being edited (if in edit mode).
632func (m *Login) GetAccountID() string {
633	return m.accountID
634}
635
636// IsEditMode returns whether the form is in edit mode.
637func (m *Login) IsEditMode() bool {
638	return m.isEditMode
639}