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