login.go

  1package tui
  2
  3import (
  4	"strconv"
  5
  6	"charm.land/bubbles/v2/textinput"
  7	tea "charm.land/bubbletea/v2"
  8	"charm.land/lipgloss/v2"
  9)
 10
 11// Login holds the state for the login/add account form.
 12type Login struct {
 13	focusIndex int
 14	inputs     []textinput.Model
 15	showCustom bool // Show custom server fields
 16	useOAuth2  bool // Use OAuth2 instead of password (for gmail)
 17	isEditMode bool // Whether we're editing an existing account
 18	accountID  string
 19	hideTips   bool
 20	width      int
 21	height     int
 22}
 23
 24const (
 25	inputProtocol = iota // "imap", "jmap", or "pop3"
 26	inputProvider        // "gmail", "icloud", or "custom"
 27	inputName
 28	inputEmail
 29	inputFetchEmail
 30	inputAuthMethod // "password" or "oauth2" (shown for gmail)
 31	inputPassword
 32	inputIMAPServer
 33	inputIMAPPort
 34	inputSMTPServer
 35	inputSMTPPort
 36	inputJMAPEndpoint // JMAP session URL
 37	inputPOP3Server
 38	inputPOP3Port
 39	inputCount
 40)
 41
 42// NewLogin creates a new login model for adding accounts.
 43func NewLogin(hideTips bool) *Login {
 44	m := &Login{
 45		inputs:   make([]textinput.Model, inputCount),
 46		hideTips: hideTips,
 47	}
 48
 49	tiStyles := ThemedTextInputStyles()
 50	var t textinput.Model
 51	for i := range m.inputs {
 52		t = textinput.New()
 53		t.CharLimit = 128
 54		t.SetStyles(tiStyles)
 55
 56		switch i {
 57		case inputProtocol:
 58			t.Placeholder = "Protocol (imap, jmap, or pop3)"
 59			t.Focus()
 60			t.Prompt = "🌐 > "
 61		case inputProvider:
 62			t.Placeholder = "Provider (gmail, icloud, or custom)"
 63			t.Prompt = "🏢 > "
 64		case inputName:
 65			t.Placeholder = "Display Name"
 66			t.Prompt = "👤 > "
 67		case inputEmail:
 68			t.Placeholder = "Username"
 69			t.Prompt = "🏠 > "
 70		case inputFetchEmail:
 71			t.Placeholder = "Email Address (comma-separated for multiple)"
 72			t.Prompt = "📧 > "
 73		case inputAuthMethod:
 74			t.Placeholder = "Auth Method (password or oauth2)"
 75			t.Prompt = "🔐 > "
 76		case inputPassword:
 77			t.Placeholder = "Password / App Password"
 78			t.EchoMode = textinput.EchoPassword
 79			t.Prompt = "🔑 > "
 80		case inputIMAPServer:
 81			t.Placeholder = "IMAP Server (e.g., imap.example.com)"
 82			t.Prompt = "📥 > "
 83		case inputIMAPPort:
 84			t.Placeholder = "IMAP Port (default: 993)"
 85			t.Prompt = "🔢 > "
 86		case inputSMTPServer:
 87			t.Placeholder = "SMTP Server (e.g., smtp.example.com)"
 88			t.Prompt = "📤 > "
 89		case inputSMTPPort:
 90			t.Placeholder = "SMTP Port (default: 587)"
 91			t.Prompt = "🔢 > "
 92		case inputJMAPEndpoint:
 93			t.Placeholder = "JMAP Session URL (e.g., https://api.fastmail.com/jmap/session)"
 94			t.Prompt = "🔗 > "
 95		case inputPOP3Server:
 96			t.Placeholder = "POP3 Server (e.g., pop.example.com)"
 97			t.Prompt = "📥 > "
 98		case inputPOP3Port:
 99			t.Placeholder = "POP3 Port (default: 995)"
100			t.Prompt = "🔢 > "
101		}
102		m.inputs[i] = t
103	}
104
105	return m
106}
107
108// Init initializes the login model.
109func (m *Login) Init() tea.Cmd {
110	return textinput.Blink
111}
112
113// protocol returns the currently selected protocol (defaults to "imap").
114func (m *Login) protocol() string {
115	p := m.inputs[inputProtocol].Value()
116	if p == "" {
117		return "imap"
118	}
119	return p
120}
121
122// visibleFields returns the ordered list of input indices the user should see
123// for the current protocol/provider/auth combination.
124func (m *Login) visibleFields() []int {
125	proto := m.protocol()
126	provider := m.inputs[inputProvider].Value()
127	isGmail := provider == "gmail"
128
129	fields := []int{inputProtocol}
130
131	switch proto {
132	case "jmap":
133		// JMAP: no provider selector, just endpoint + common fields
134		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputPassword, inputJMAPEndpoint)
135	case "pop3":
136		// POP3: custom server fields + SMTP for sending
137		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputPassword,
138			inputPOP3Server, inputPOP3Port, inputSMTPServer, inputSMTPPort)
139	default:
140		// IMAP (default): existing flow
141		fields = append(fields, inputProvider, inputName, inputEmail, inputFetchEmail)
142		if isGmail {
143			fields = append(fields, inputAuthMethod)
144		}
145		if !m.useOAuth2 {
146			fields = append(fields, inputPassword)
147		}
148		if m.showCustom {
149			fields = append(fields, inputIMAPServer, inputIMAPPort, inputSMTPServer, inputSMTPPort)
150		}
151	}
152
153	return fields
154}
155
156// Update handles messages for the login model.
157func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
158	switch msg := msg.(type) {
159	case tea.WindowSizeMsg:
160		m.width = msg.Width
161		m.height = msg.Height
162		for i := range m.inputs {
163			m.inputs[i].SetWidth(msg.Width - 6)
164		}
165
166	case tea.KeyPressMsg:
167		switch msg.String() {
168		case "esc":
169			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
170
171		case "enter":
172			m.updateFlags()
173			visible := m.visibleFields()
174			lastField := visible[len(visible)-1]
175
176			if m.focusIndex == lastField {
177				return m, m.submitForm()
178			}
179			fallthrough
180
181		case "tab", "shift+tab", "up", "down":
182			s := msg.String()
183			m.updateFlags()
184			visible := m.visibleFields()
185
186			// Find current position in visible fields
187			curPos := 0
188			for i, f := range visible {
189				if f == m.focusIndex {
190					curPos = i
191					break
192				}
193			}
194
195			if s == "up" || s == "shift+tab" {
196				curPos--
197			} else {
198				curPos++
199			}
200
201			if curPos >= len(visible) {
202				curPos = 0
203			} else if curPos < 0 {
204				curPos = len(visible) - 1
205			}
206
207			m.focusIndex = visible[curPos]
208
209			cmds := make([]tea.Cmd, len(m.inputs))
210			for i := 0; i < len(m.inputs); i++ {
211				if i == m.focusIndex {
212					cmds[i] = m.inputs[i].Focus()
213				} else {
214					m.inputs[i].Blur()
215				}
216			}
217			return m, tea.Batch(cmds...)
218		}
219	}
220
221	// Update the focused input field
222	var cmds = make([]tea.Cmd, len(m.inputs))
223	for i := range m.inputs {
224		m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
225	}
226
227	m.updateFlags()
228
229	return m, tea.Batch(cmds...)
230}
231
232// updateFlags recalculates showCustom and useOAuth2 from current inputs.
233func (m *Login) updateFlags() {
234	provider := m.inputs[inputProvider].Value()
235	m.showCustom = provider == "custom"
236	m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
237}
238
239// submitForm builds and returns a Credentials message from the current inputs.
240func (m *Login) submitForm() func() tea.Msg {
241	imapPort := 993
242	smtpPort := 587
243	pop3Port := 995
244	if m.inputs[inputIMAPPort].Value() != "" {
245		if p, err := strconv.Atoi(m.inputs[inputIMAPPort].Value()); err == nil {
246			imapPort = p
247		}
248	}
249	if m.inputs[inputSMTPPort].Value() != "" {
250		if p, err := strconv.Atoi(m.inputs[inputSMTPPort].Value()); err == nil {
251			smtpPort = p
252		}
253	}
254	if m.inputs[inputPOP3Port].Value() != "" {
255		if p, err := strconv.Atoi(m.inputs[inputPOP3Port].Value()); err == nil {
256			pop3Port = p
257		}
258	}
259
260	authMethod := "password"
261	if m.useOAuth2 {
262		authMethod = "oauth2"
263	}
264
265	proto := m.protocol()
266
267	return func() tea.Msg {
268		return Credentials{
269			Protocol:     proto,
270			Provider:     m.inputs[inputProvider].Value(),
271			Name:         m.inputs[inputName].Value(),
272			Host:         m.inputs[inputEmail].Value(),
273			FetchEmail:   m.inputs[inputFetchEmail].Value(),
274			Password:     m.inputs[inputPassword].Value(),
275			IMAPServer:   m.inputs[inputIMAPServer].Value(),
276			IMAPPort:     imapPort,
277			SMTPServer:   m.inputs[inputSMTPServer].Value(),
278			SMTPPort:     smtpPort,
279			AuthMethod:   authMethod,
280			JMAPEndpoint: m.inputs[inputJMAPEndpoint].Value(),
281			POP3Server:   m.inputs[inputPOP3Server].Value(),
282			POP3Port:     pop3Port,
283		}
284	}
285}
286
287// View renders the login form.
288func (m *Login) View() tea.View {
289	title := "Add Account"
290	if m.isEditMode {
291		title = "Edit Account"
292	}
293
294	proto := m.protocol()
295
296	tip := ""
297	switch m.focusIndex {
298	case inputProtocol:
299		tip = "Choose the protocol: imap (default), jmap, or pop3."
300	case inputProvider:
301		tip = "Enter your email provider (e.g., gmail, icloud) or 'custom'."
302	case inputName:
303		tip = "The name that will appear on emails you send."
304	case inputEmail:
305		tip = "Your full email address used to log in."
306	case inputFetchEmail:
307		tip = "The email address to fetch messages for (comma-separated for multiple, e.g. me@icloud.com,me@mac.com)."
308	case inputAuthMethod:
309		tip = "Type 'oauth2' for Gmail OAuth2 or 'password' for app password."
310	case inputPassword:
311		tip = "Your password or an app-specific password if using 2FA."
312	case inputIMAPServer:
313		tip = "The server address for receiving emails."
314	case inputIMAPPort:
315		tip = "The port for the IMAP server (usually 993 for SSL)."
316	case inputSMTPServer:
317		tip = "The server address for sending emails."
318	case inputSMTPPort:
319		tip = "The port for the SMTP server (usually 587 for TLS)."
320	case inputJMAPEndpoint:
321		tip = "The JMAP session resource URL (e.g., https://api.fastmail.com/jmap/session)."
322	case inputPOP3Server:
323		tip = "The POP3 server address for receiving emails."
324	case inputPOP3Port:
325		tip = "The port for the POP3 server (usually 995 for SSL)."
326	}
327
328	views := []string{
329		titleStyle.Render(title),
330		"Enter your email account credentials.",
331		"",
332		m.inputs[inputProtocol].View(),
333	}
334
335	switch proto {
336	case "jmap":
337		views = append(views,
338			m.inputs[inputName].View(),
339			m.inputs[inputEmail].View(),
340			m.inputs[inputFetchEmail].View(),
341			m.inputs[inputPassword].View(),
342			"",
343			listHeader.Render("JMAP Settings:"),
344			m.inputs[inputJMAPEndpoint].View(),
345		)
346	case "pop3":
347		views = append(views,
348			m.inputs[inputName].View(),
349			m.inputs[inputEmail].View(),
350			m.inputs[inputFetchEmail].View(),
351			m.inputs[inputPassword].View(),
352			"",
353			listHeader.Render("POP3 Server Settings:"),
354			m.inputs[inputPOP3Server].View(),
355			m.inputs[inputPOP3Port].View(),
356			"",
357			listHeader.Render("SMTP Settings (for sending):"),
358			m.inputs[inputSMTPServer].View(),
359			m.inputs[inputSMTPPort].View(),
360		)
361	default:
362		// IMAP flow
363		isGmail := m.inputs[inputProvider].Value() == "gmail"
364		views = append(views,
365			m.inputs[inputProvider].View(),
366			m.inputs[inputName].View(),
367			m.inputs[inputEmail].View(),
368			m.inputs[inputFetchEmail].View(),
369		)
370
371		if isGmail {
372			views = append(views, m.inputs[inputAuthMethod].View())
373		}
374
375		if !m.useOAuth2 {
376			views = append(views, m.inputs[inputPassword].View())
377		} else {
378			views = append(views, accountEmailStyle.Render("OAuth2 selected — browser authorization will open after submit"))
379		}
380
381		if m.showCustom {
382			customHint := accountEmailStyle.Render("Custom provider selected - configure server settings below")
383			views = append(views,
384				"",
385				customHint,
386				m.inputs[inputIMAPServer].View(),
387				m.inputs[inputIMAPPort].View(),
388				m.inputs[inputSMTPServer].View(),
389				m.inputs[inputSMTPPort].View(),
390			)
391		}
392	}
393
394	views = append(views, "")
395	if !m.hideTips && tip != "" {
396		views = append(views, TipStyle.Render("Tip: "+tip))
397	}
398	views = append(views, helpStyle.Render("\nenter: save • tab: next field • esc: back to menu"))
399
400	return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, views...))
401}
402
403// SetEditMode sets the login form to edit an existing account.
404func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, jmapEndpoint, pop3Server string, pop3Port int) {
405	m.isEditMode = true
406	m.accountID = accountID
407
408	if protocol == "" {
409		protocol = "imap"
410	}
411	m.inputs[inputProtocol].SetValue(protocol)
412	m.inputs[inputProvider].SetValue(provider)
413	m.inputs[inputName].SetValue(name)
414	m.inputs[inputEmail].SetValue(email)
415	m.inputs[inputFetchEmail].SetValue(fetchEmail)
416	m.showCustom = provider == "custom"
417
418	if m.showCustom {
419		m.inputs[inputIMAPServer].SetValue(imapServer)
420		if imapPort != 0 {
421			m.inputs[inputIMAPPort].SetValue(strconv.Itoa(imapPort))
422		}
423		m.inputs[inputSMTPServer].SetValue(smtpServer)
424		if smtpPort != 0 {
425			m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort))
426		}
427	}
428
429	if jmapEndpoint != "" {
430		m.inputs[inputJMAPEndpoint].SetValue(jmapEndpoint)
431	}
432	if pop3Server != "" {
433		m.inputs[inputPOP3Server].SetValue(pop3Server)
434	}
435	if pop3Port != 0 {
436		m.inputs[inputPOP3Port].SetValue(strconv.Itoa(pop3Port))
437	}
438	// Also set SMTP for POP3
439	if protocol == "pop3" {
440		m.inputs[inputSMTPServer].SetValue(smtpServer)
441		if smtpPort != 0 {
442			m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort))
443		}
444	}
445}
446
447// GetAccountID returns the account ID being edited (if in edit mode).
448func (m *Login) GetAccountID() string {
449	return m.accountID
450}
451
452// IsEditMode returns whether the form is in edit mode.
453func (m *Login) IsEditMode() bool {
454	return m.isEditMode
455}