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 or oauth2)"
 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, 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
318// validPort parses a port string and returns the integer value if it is within
319// the valid TCP/UDP port range (1-65535). Returns the fallback if the string is
320// empty or invalid.
321func validPort(s string, fallback int) int {
322	if s == "" {
323		return fallback
324	}
325	p, err := strconv.Atoi(s)
326	if err != nil || p < 1 || p > 65535 {
327		return fallback
328	}
329	return p
330}
331
332// submitForm builds and returns a Credentials message from the current inputs.
333func (m *Login) submitForm() func() tea.Msg {
334	imapPort := validPort(m.inputs[inputIMAPPort].Value(), 993)
335	smtpPort := validPort(m.inputs[inputSMTPPort].Value(), 587)
336	pop3Port := validPort(m.inputs[inputPOP3Port].Value(), 995)
337
338	authMethod := "password"
339	if m.useOAuth2 {
340		authMethod = "oauth2"
341	}
342
343	proto := m.protocol()
344
345	insecure := m.inputs[inputInsecure].Value() == "true"
346	catchAll := m.inputs[inputCatchAll].Value() == "true"
347
348	return func() tea.Msg {
349		return Credentials{
350			Protocol:     proto,
351			Provider:     m.inputs[inputProvider].Value(),
352			Name:         m.inputs[inputName].Value(),
353			Host:         m.inputs[inputEmail].Value(),
354			FetchEmail:   m.inputs[inputFetchEmail].Value(),
355			SendAsEmail:  m.inputs[inputSendAsEmail].Value(),
356			CatchAll:     catchAll,
357			Password:     m.inputs[inputPassword].Value(),
358			IMAPServer:   m.inputs[inputIMAPServer].Value(),
359			IMAPPort:     imapPort,
360			SMTPServer:   m.inputs[inputSMTPServer].Value(),
361			SMTPPort:     smtpPort,
362			Insecure:     insecure,
363			AuthMethod:   authMethod,
364			JMAPEndpoint: m.inputs[inputJMAPEndpoint].Value(),
365			POP3Server:   m.inputs[inputPOP3Server].Value(),
366			POP3Port:     pop3Port,
367			MaildirPath:  m.inputs[inputMaildirPath].Value(),
368		}
369	}
370}
371
372// viewProtocolCombobox renders the protocol selector as a segmented combobox,
373// highlighting the current selection and dimming the alternatives.
374func (m *Login) viewProtocolCombobox() string {
375	th := theme.ActiveTheme
376	focused := m.focusIndex == inputProtocol
377	cur := m.protocol()
378
379	promptColor := th.MutedText
380	if focused {
381		promptColor = th.AccentText
382	}
383	prompt := lipgloss.NewStyle().Foreground(promptColor).Render("🌐 > ")
384
385	selStyle := lipgloss.NewStyle().Foreground(th.Accent).Bold(true)
386	optStyle := lipgloss.NewStyle().Foreground(th.DimText)
387
388	parts := make([]string, len(loginProtocols))
389	for i, p := range loginProtocols {
390		switch {
391		case p == cur && focused:
392			parts[i] = selStyle.Render("‹ " + p + " ›")
393		case p == cur:
394			parts[i] = selStyle.Render("  " + p + "  ")
395		default:
396			parts[i] = optStyle.Render("  " + p + "  ")
397		}
398	}
399
400	return prompt + strings.Join(parts, "")
401}
402
403// protocolFieldViews returns the rendered input fields specific to the given
404// protocol, in display order.
405func (m *Login) protocolFieldViews(proto string) []string {
406	common := []string{
407		m.inputs[inputName].View(),
408		m.inputs[inputEmail].View(),
409		m.inputs[inputFetchEmail].View(),
410		m.inputs[inputSendAsEmail].View(),
411		m.inputs[inputCatchAll].View(),
412	}
413
414	switch proto {
415	case protocolJMAP:
416		return append(common,
417			m.inputs[inputPassword].View(),
418			"",
419			listHeader.Render("JMAP Settings:"),
420			m.inputs[inputJMAPEndpoint].View(),
421		)
422	case protocolPOP3:
423		return append(common,
424			m.inputs[inputPassword].View(),
425			"",
426			listHeader.Render("POP3 Server Settings:"),
427			m.inputs[inputPOP3Server].View(),
428			m.inputs[inputPOP3Port].View(),
429			"",
430			listHeader.Render("SMTP Settings (for sending):"),
431			m.inputs[inputSMTPServer].View(),
432			m.inputs[inputSMTPPort].View(),
433			m.inputs[inputInsecure].View(),
434		)
435	case protocolMaildir:
436		return append(common,
437			"",
438			listHeader.Render("Maildir Settings:"),
439			m.inputs[inputMaildirPath].View(),
440		)
441	default:
442		return m.imapFieldViews(common)
443	}
444}
445
446// imapFieldViews renders the IMAP-specific fields (provider selector, optional
447// OAuth2/password, and custom server settings) appended after the common fields.
448func (m *Login) imapFieldViews(common []string) []string {
449	provider := m.inputs[inputProvider].Value()
450	hasOAuth := provider == "gmail" || provider == "outlook"
451
452	views := append([]string{m.inputs[inputProvider].View()}, common...)
453
454	if hasOAuth {
455		views = append(views, m.inputs[inputAuthMethod].View())
456	}
457
458	if !m.useOAuth2 {
459		views = append(views, m.inputs[inputPassword].View())
460	} else {
461		views = append(views, accountEmailStyle.Render("OAuth2 selected — browser authorization will open after submit"))
462	}
463
464	if m.showCustom {
465		views = append(views,
466			"",
467			accountEmailStyle.Render("Custom provider selected - configure server settings below"),
468			m.inputs[inputIMAPServer].View(),
469			m.inputs[inputIMAPPort].View(),
470			m.inputs[inputSMTPServer].View(),
471			m.inputs[inputSMTPPort].View(),
472			m.inputs[inputInsecure].View(),
473		)
474	}
475
476	return views
477}
478
479// View renders the login form.
480func (m *Login) View() tea.View {
481	title := "Add Account"
482	if m.isEditMode {
483		title = "Edit Account"
484	}
485
486	proto := m.protocol()
487
488	tip := ""
489	switch m.focusIndex {
490	case inputProtocol:
491		tip = "Use ←/→ to choose the protocol: imap (default), jmap, pop3, or maildir."
492	case inputProvider:
493		tip = "Enter your email provider (e.g., gmail, outlook, icloud) or 'custom'."
494	case inputName:
495		tip = "The name that will appear on emails you send."
496	case inputEmail:
497		tip = "Your full email address used to log in."
498	case inputFetchEmail:
499		tip = "The email address to fetch messages for (comma-separated for multiple, e.g. me@icloud.com,me@mac.com)."
500	case inputSendAsEmail:
501		tip = "Optional From header override for outgoing email. Leave blank to send as the fetched address."
502	case inputAuthMethod:
503		tip = "Type 'oauth2' for OAuth2 or 'password' for app password."
504	case inputPassword:
505		tip = "Your password or an app-specific password if using 2FA."
506	case inputIMAPServer:
507		tip = "The server address for receiving emails."
508	case inputIMAPPort:
509		tip = "The port for the IMAP server (usually 993 for SSL)."
510	case inputSMTPServer:
511		tip = "The server address for sending emails."
512	case inputSMTPPort:
513		tip = "The port for the SMTP server (usually 587 for TLS)."
514	case inputInsecure:
515		tip = "Type 'true' to disable TLS certificate verification (not recommended)."
516	case inputCatchAll:
517		tip = "Type 'true' to show all inbox messages regardless of To address (useful for catch-all domains)."
518	case inputJMAPEndpoint:
519		tip = "The JMAP session resource URL (e.g., https://api.fastmail.com/jmap/session)."
520	case inputPOP3Server:
521		tip = "The POP3 server address for receiving emails."
522	case inputPOP3Port:
523		tip = "The port for the POP3 server (usually 995 for SSL)."
524	case inputMaildirPath:
525		tip = "Local path to a Maildir directory (cur/new/tmp). Subfolders use .Foldername (Maildir++)."
526	}
527
528	views := []string{
529		titleStyle.Render(title),
530		"Enter your email account credentials.",
531		"",
532		m.viewProtocolCombobox(),
533	}
534
535	views = append(views, m.protocolFieldViews(proto)...)
536
537	views = append(views, "")
538	if !m.hideTips && tip != "" {
539		views = append(views, TipStyle.Render("Tip: "+tip))
540	}
541	helpLine := "enter: save • tab: next field • esc: back to menu"
542	if m.focusIndex == inputProtocol {
543		helpLine += " • ←/→: change protocol"
544	}
545	if m.focusIndex == inputPassword {
546		helpLine += " • ctrl+v: toggle password visibility"
547	}
548	views = append(views, helpStyle.Render("\n"+helpLine))
549
550	return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, views...))
551}
552
553// SetEditMode sets the login form to edit an existing account.
554func (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) {
555	m.isEditMode = true
556	m.accountID = accountID
557
558	if protocol == "" {
559		protocol = protocolIMAP
560	}
561	m.inputs[inputProtocol].SetValue(protocol)
562	m.inputs[inputProvider].SetValue(provider)
563	m.inputs[inputName].SetValue(name)
564	m.inputs[inputEmail].SetValue(email)
565	m.inputs[inputFetchEmail].SetValue(fetchEmail)
566	m.inputs[inputSendAsEmail].SetValue(sendAsEmail)
567	if catchAll {
568		m.inputs[inputCatchAll].SetValue("true")
569	} else {
570		m.inputs[inputCatchAll].SetValue("false")
571	}
572	m.showCustom = provider == "custom"
573
574	if m.showCustom {
575		m.inputs[inputIMAPServer].SetValue(imapServer)
576		if insecure {
577			m.inputs[inputInsecure].SetValue("true")
578		} else {
579			m.inputs[inputInsecure].SetValue("false")
580		}
581		if imapPort != 0 {
582			m.inputs[inputIMAPPort].SetValue(strconv.Itoa(imapPort))
583		}
584		m.inputs[inputSMTPServer].SetValue(smtpServer)
585		if smtpPort != 0 {
586			m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort))
587		}
588	}
589
590	if jmapEndpoint != "" {
591		m.inputs[inputJMAPEndpoint].SetValue(jmapEndpoint)
592	}
593	if pop3Server != "" {
594		m.inputs[inputPOP3Server].SetValue(pop3Server)
595	}
596	if pop3Port != 0 {
597		m.inputs[inputPOP3Port].SetValue(strconv.Itoa(pop3Port))
598	}
599	// Also set SMTP for POP3
600	if protocol == protocolPOP3 {
601		m.inputs[inputSMTPServer].SetValue(smtpServer)
602		if smtpPort != 0 {
603			m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort))
604		}
605	}
606	if maildirPath != "" {
607		m.inputs[inputMaildirPath].SetValue(maildirPath)
608	}
609}
610
611// GetAccountID returns the account ID being edited (if in edit mode).
612func (m *Login) GetAccountID() string {
613	return m.accountID
614}
615
616// IsEditMode returns whether the form is in edit mode.
617func (m *Login) IsEditMode() bool {
618	return m.isEditMode
619}