Skip to main content

max / goingson

38.3 KB · 802 lines History Blame Raw
1 /**
2 * GoingsOn - Email Accounts Module
3 * Account CRUD, connection testing, sync, and OAuth flow.
4 * Extends GoingsOn.emails with account management functions.
5 */
6
7 (function() {
8 'use strict';
9 const esc = GoingsOn.utils.escapeHtml;
10 const escAttr = GoingsOn.utils.escapeAttr;
11
12 // ============ Account Form Builder ============
13
14 /**
15 * Sync interval options used by both add and edit forms.
16 */
17 const SYNC_INTERVAL_OPTIONS = [
18 { value: '', label: 'Disabled' },
19 { value: '5', label: 'Every 5 minutes' },
20 { value: '15', label: 'Every 15 minutes (default)' },
21 { value: '30', label: 'Every 30 minutes' },
22 { value: '60', label: 'Every hour' },
23 ];
24
25 /**
26 * Build the shared IMAP/SMTP account form HTML.
27 * @param {Object} opts
28 * @param {string} opts.formId - Form element ID
29 * @param {string} opts.idPrefix - ID prefix for inputs (e.g. 'acct' or 'edit-acct')
30 * @param {string} opts.onSubmit - Form onsubmit attribute value
31 * @param {Object} opts.values - Current field values (empty strings / defaults for add)
32 * @param {boolean} opts.isEdit - Whether this is the edit form
33 * @param {string} opts.submitLabel - Submit button label
34 * @returns {string} HTML string for the form
35 */
36 function buildAccountFormHtml(opts) {
37 const { formId, idPrefix, onSubmit, values, isEdit, submitLabel } = opts;
38
39 const passwordLabel = isEdit ? 'Password (leave empty to keep current)' : 'Password';
40 const passwordRequired = isEdit ? '' : 'required';
41 const passwordPlaceholder = isEdit ? 'Enter new password or leave empty' : 'your password';
42
43 const archiveHint = isEdit
44 ? 'Use Test Connection to see available folders on this account.'
45 : 'Gmail: [Gmail]/All Mail, Fastmail: Archive. Use Test Connection to see available folders.';
46
47 const syncOptionsHtml = SYNC_INTERVAL_OPTIONS.map(opt => {
48 let selected = '';
49 if (isEdit) {
50 // For edit: match against current value (null maps to '')
51 const current = values.syncIntervalMinutes != null ? String(values.syncIntervalMinutes) : '';
52 selected = (opt.value === current) ? 'selected' : '';
53 } else {
54 // For add: default to 15 minutes
55 selected = (opt.value === '15') ? 'selected' : '';
56 }
57 return `<option value="${escAttr(opt.value)}" ${selected}>${esc(opt.label)}</option>`;
58 }).join('');
59
60 // For new accounts, check if server fields have been pre-filled (edit mode always shows them)
61 const hasServerValues = isEdit || !!(values.imapServer || values.smtpServer);
62 const advancedExpanded = hasServerValues ? 'expanded' : '';
63 const advancedHidden = hasServerValues ? '' : 'hidden';
64
65 const ff = GoingsOn.ui.renderFormField;
66 const syncIntervalOpts = SYNC_INTERVAL_OPTIONS.map(opt => {
67 let selected;
68 if (isEdit) {
69 const current = values.syncIntervalMinutes != null ? String(values.syncIntervalMinutes) : '';
70 selected = opt.value === current;
71 } else {
72 selected = opt.value === '15';
73 }
74 return { value: opt.value, label: opt.label, selected };
75 });
76
77 return `
78 <form id="${formId}" onsubmit="${escAttr(onSubmit)}">
79 ${ff({ kind: 'text', name: 'account_name', id: `${idPrefix}-name`, label: 'Account Name', value: values.accountName || '', placeholder: 'Personal, Work, etc.', required: true })}
80 <div class="form-group">
81 <label class="form-label" for="${idPrefix}-email">Email Address</label>
82 <input type="email" class="form-input" id="${idPrefix}-email" name="email_address" required placeholder="you@example.com" value="${escAttr(values.emailAddress || '')}">
83 <div id="${idPrefix}-detect-status" class="email-detect-status"></div>
84 <div id="${idPrefix}-detect-note" class="email-detect-note hidden"></div>
85 </div>
86 ${ff({ kind: 'text', name: 'username', id: `${idPrefix}-username`, label: 'Username', value: values.username || '', placeholder: 'Usually your email address', required: true })}
87 ${ff({ kind: 'password', name: 'password', id: `${idPrefix}-password`, label: passwordLabel, placeholder: passwordPlaceholder, required: !isEdit })}
88 ${ff({ kind: 'text', name: 'archive_folder_name', id: `${idPrefix}-archive-folder`, label: 'Archive Folder Name', value: values.archiveFolderName || 'Archive', placeholder: 'Archive', hint: archiveHint })}
89 ${ff({ kind: 'textarea', name: 'email_signature', id: `${idPrefix}-signature`, label: 'Email Signature', value: values.emailSignature || '', placeholder: '-- \nYour Name', hint: 'Appended to outbound emails. Plain text only.', attrs: { rows: 3 } })}
90 <button type="button" class="form-more-toggle ${advancedExpanded}" onclick="this.classList.toggle('expanded'); this.nextElementSibling.classList.toggle('hidden');">Advanced settings</button>
91 <div class="${advancedHidden}">
92 <div class="form-grid-2">
93 ${ff({ kind: 'text', name: 'imap_server', id: `${idPrefix}-imap-server`, label: 'IMAP Server', value: values.imapServer || '', placeholder: 'imap.example.com', required: true })}
94 ${ff({ kind: 'number', name: 'imap_port', id: `${idPrefix}-imap-port`, label: 'IMAP Port', value: values.imapPort || 993, required: true })}
95 </div>
96 <div class="form-grid-2">
97 ${ff({ kind: 'text', name: 'smtp_server', id: `${idPrefix}-smtp-server`, label: 'SMTP Server', value: values.smtpServer || '', placeholder: 'smtp.example.com', required: true })}
98 ${ff({ kind: 'number', name: 'smtp_port', id: `${idPrefix}-smtp-port`, label: 'SMTP Port', value: values.smtpPort || 587, required: true })}
99 </div>
100 <div class="form-group">
101 <label class="form-checkbox-label">
102 <input type="checkbox" id="${idPrefix}-use-tls" name="use_tls" ${values.useTls !== false ? 'checked' : ''}>
103 <span>Use TLS/SSL</span>
104 </label>
105 </div>
106 <div class="form-group">
107 <label class="form-checkbox-label">
108 <input type="checkbox" id="${idPrefix}-notify" name="notify_new_emails" ${values.notifyNewEmails ? 'checked' : ''}>
109 <span>Notify on new emails</span>
110 </label>
111 <div class="form-hint">Show a system notification when new emails arrive during auto-sync. Off by default.</div>
112 </div>
113 ${ff({ kind: 'select', name: 'sync_interval_minutes', id: `${idPrefix}-sync-interval`, label: 'Auto-sync Interval', options: syncIntervalOpts, hint: 'Automatically check for new emails at this interval.' })}
114 </div>
115 <div class="form-actions">
116 <button type="button" class="btn btn-secondary" onclick="GoingsOn.emails.refreshAccountsView()">Cancel</button>
117 <button type="submit" class="btn btn-primary">${esc(submitLabel)}</button>
118 </div>
119 </form>
120 `;
121 }
122
123 // ============ IMAP/SMTP Auto-Detect ============
124
125 /**
126 * Well-known email provider server settings.
127 * Key is the email domain; value has imap, smtp, archive defaults.
128 */
129 // App-password notes shared across providers that require them. The note is shown
130 // beneath the email field when the domain is detected; the link opens in the OS
131 // browser via the regular anchor (Tauri allows external http(s) targets).
132 const NOTE_APPLE = 'Apple requires an <strong>app-specific password</strong> for third-party mail apps — your normal iCloud password will not work. Generate one at <a href="https://appleid.apple.com/account/manage" target="_blank" rel="noopener">appleid.apple.com</a> → Sign-In and Security → App-Specific Passwords, then paste it in the Password field below.';
133 const NOTE_FASTMAIL = 'Fastmail requires an <strong>app password</strong> (not your normal password). Create one at <a href="https://app.fastmail.com/settings/security/integrations" target="_blank" rel="noopener">fastmail.com → Settings → Privacy &amp; Security → Integrations</a>, then paste it in the Password field below.';
134 const NOTE_GOOGLE = 'Gmail requires an <strong>app password</strong> (your normal Google password will not work). With 2-Step Verification enabled, create one at <a href="https://myaccount.google.com/apppasswords" target="_blank" rel="noopener">myaccount.google.com/apppasswords</a> and paste it below.';
135 const NOTE_YAHOO = 'Yahoo requires an <strong>app password</strong>. Create one at <a href="https://login.yahoo.com/account/security" target="_blank" rel="noopener">Yahoo Account Security → Generate app password</a> and paste it below.';
136 const NOTE_AOL = 'AOL requires an <strong>app password</strong>. Create one at <a href="https://login.aol.com/account/security" target="_blank" rel="noopener">AOL Account Security → Generate app password</a> and paste it below.';
137 const NOTE_OUTLOOK = 'If your Microsoft account has 2-Step Verification on, generate an <strong>app password</strong> at <a href="https://account.microsoft.com/security" target="_blank" rel="noopener">account.microsoft.com/security</a> → Advanced security options. Otherwise your normal password works.';
138 const NOTE_PROTON = 'Proton Mail does not allow direct IMAP/SMTP — you must run <a href="https://proton.me/mail/bridge" target="_blank" rel="noopener">Proton Bridge</a> locally and use the bridge-generated credentials. Bridge is a paid plan feature.';
139
140 const PROVIDER_SETTINGS = {
141 'gmail.com': { imap: 'imap.gmail.com', imapPort: 993, smtp: 'smtp.gmail.com', smtpPort: 587, archive: '[Gmail]/All Mail', name: 'Gmail', note: NOTE_GOOGLE },
142 'googlemail.com': { imap: 'imap.gmail.com', imapPort: 993, smtp: 'smtp.gmail.com', smtpPort: 587, archive: '[Gmail]/All Mail', name: 'Gmail', note: NOTE_GOOGLE },
143 'fastmail.com': { imap: 'imap.fastmail.com', imapPort: 993, smtp: 'smtp.fastmail.com', smtpPort: 587, archive: 'Archive', name: 'Fastmail', note: NOTE_FASTMAIL },
144 'outlook.com': { imap: 'outlook.office365.com', imapPort: 993, smtp: 'smtp.office365.com', smtpPort: 587, archive: 'Archive', name: 'Outlook', note: NOTE_OUTLOOK },
145 'hotmail.com': { imap: 'outlook.office365.com', imapPort: 993, smtp: 'smtp.office365.com', smtpPort: 587, archive: 'Archive', name: 'Hotmail', note: NOTE_OUTLOOK },
146 'live.com': { imap: 'outlook.office365.com', imapPort: 993, smtp: 'smtp.office365.com', smtpPort: 587, archive: 'Archive', name: 'Outlook', note: NOTE_OUTLOOK },
147 'yahoo.com': { imap: 'imap.mail.yahoo.com', imapPort: 993, smtp: 'smtp.mail.yahoo.com', smtpPort: 587, archive: 'Archive', name: 'Yahoo', note: NOTE_YAHOO },
148 'icloud.com': { imap: 'imap.mail.me.com', imapPort: 993, smtp: 'smtp.mail.me.com', smtpPort: 587, archive: 'Archive', name: 'iCloud', note: NOTE_APPLE },
149 'me.com': { imap: 'imap.mail.me.com', imapPort: 993, smtp: 'smtp.mail.me.com', smtpPort: 587, archive: 'Archive', name: 'iCloud', note: NOTE_APPLE },
150 'mac.com': { imap: 'imap.mail.me.com', imapPort: 993, smtp: 'smtp.mail.me.com', smtpPort: 587, archive: 'Archive', name: 'iCloud', note: NOTE_APPLE },
151 'protonmail.com': { imap: 'imap.protonmail.ch', imapPort: 993, smtp: 'smtp.protonmail.ch', smtpPort: 587, archive: 'Archive', name: 'Proton Mail', note: NOTE_PROTON },
152 'proton.me': { imap: 'imap.protonmail.ch', imapPort: 993, smtp: 'smtp.protonmail.ch', smtpPort: 587, archive: 'Archive', name: 'Proton Mail', note: NOTE_PROTON },
153 'zoho.com': { imap: 'imap.zoho.com', imapPort: 993, smtp: 'smtp.zoho.com', smtpPort: 587, archive: 'Archive', name: 'Zoho' },
154 'aol.com': { imap: 'imap.aol.com', imapPort: 993, smtp: 'smtp.aol.com', smtpPort: 587, archive: 'Archive', name: 'AOL', note: NOTE_AOL },
155 };
156
157 /**
158 * Attach auto-detect behavior to an email input field.
159 * When the user types a recognized domain, auto-fills server fields.
160 * @param {string} idPrefix - The form field ID prefix (e.g. 'acct')
161 */
162 function attachAutoDetect(idPrefix) {
163 const emailEl = document.getElementById(`${idPrefix}-email`);
164 if (!emailEl) return;
165
166 let lastDetectedDomain = null;
167
168 const detect = () => {
169 const email = emailEl.value.trim();
170 const domain = email.split('@')[1]?.toLowerCase();
171 if (!domain || domain === lastDetectedDomain) return;
172
173 const settings = PROVIDER_SETTINGS[domain];
174 const statusEl = document.getElementById(`${idPrefix}-detect-status`);
175
176 const noteEl = document.getElementById(`${idPrefix}-detect-note`);
177
178 if (!settings) {
179 if (statusEl && domain.includes('.')) {
180 statusEl.textContent = 'Unknown provider — fill in server details under Advanced settings';
181 statusEl.style.color = 'var(--text-secondary)';
182 }
183 if (noteEl) { noteEl.classList.add('hidden'); noteEl.innerHTML = ''; }
184 return;
185 }
186
187 lastDetectedDomain = domain;
188
189 const imapEl = document.getElementById(`${idPrefix}-imap-server`);
190 const imapPortEl = document.getElementById(`${idPrefix}-imap-port`);
191 const smtpEl = document.getElementById(`${idPrefix}-smtp-server`);
192 const smtpPortEl = document.getElementById(`${idPrefix}-smtp-port`);
193 const usernameEl = document.getElementById(`${idPrefix}-username`);
194 const archiveEl = document.getElementById(`${idPrefix}-archive-folder`);
195
196 // Auto-fill server fields
197 if (imapEl && (!imapEl.value || imapEl.value === 'imap.example.com')) imapEl.value = settings.imap;
198 if (imapPortEl && (imapPortEl.value === '993' || !imapPortEl.value)) imapPortEl.value = settings.imapPort;
199 if (smtpEl && (!smtpEl.value || smtpEl.value === 'smtp.example.com')) smtpEl.value = settings.smtp;
200 if (smtpPortEl && (smtpPortEl.value === '587' || !smtpPortEl.value)) smtpPortEl.value = settings.smtpPort;
201 if (usernameEl && !usernameEl.value) usernameEl.value = email;
202 if (archiveEl && (archiveEl.value === 'Archive' || !archiveEl.value)) archiveEl.value = settings.archive;
203
204 if (statusEl) {
205 statusEl.textContent = `Detected ${settings.name || domain} — server settings auto-filled`;
206 statusEl.style.color = 'var(--accent-green)';
207 }
208 if (noteEl) {
209 if (settings.note) {
210 noteEl.innerHTML = settings.note;
211 noteEl.classList.remove('hidden');
212 } else {
213 noteEl.classList.add('hidden');
214 noteEl.innerHTML = '';
215 }
216 }
217 };
218
219 emailEl.addEventListener('input', detect);
220 emailEl.addEventListener('change', detect);
221 }
222
223 // ============ Email Accounts ============
224
225 async function loadAccounts() {
226 try {
227 const accounts = await GoingsOn.api.emailAccounts.list();
228 GoingsOn.state.set('emailAccounts', accounts);
229 } catch (err) {
230 console.error('Failed to load email accounts:', err);
231 GoingsOn.state.set('emailAccounts', []);
232 }
233 }
234
235 // Helper to get provider display name from auth_type
236 /**
237 * Map an auth_type string to a human-readable provider name.
238 * @param {string} authType - Auth type enum value (e.g. 'OAuth2Fastmail')
239 * @returns {string|null} Provider display name, or null for Password auth
240 */
241 function getAuthTypeDisplay(authType) {
242 const providers = {
243 'OAuth2Fastmail': 'Fastmail',
244 'OAuth2Google': 'Google',
245 'OAuth2Microsoft': 'Microsoft',
246 'OAuth2Yahoo': 'Yahoo',
247 };
248 return providers[authType] || null;
249 }
250
251 function buildAccountsListHtml(accounts) {
252 if (accounts.length === 0) {
253 return '<p class="empty-state empty-state--compact text-secondary">No email accounts configured</p>';
254 }
255 return accounts.map(a => {
256 const isOAuth = a.auth_type && a.auth_type !== 'Password';
257 const providerName = getAuthTypeDisplay(a.auth_type);
258 const oauthBadge = providerName
259 ? `<span class="account-row-provider-badge">${providerName}</span>`
260 : '';
261
262 const editBtn = isOAuth
263 ? `<button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.reconnectOAuth('${escAttr(a.id)}')">Reconnect</button>`
264 : `<button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.editAccount('${escAttr(a.id)}')">Edit</button>`;
265
266 return `
267 <div class="account-row">
268 <div class="account-row-actions">
269 <div class="account-row-info">
270 <div class="account-row-name">${esc(a.account_name)}${oauthBadge}</div>
271 <div class="account-row-meta">${esc(a.email_address)}</div>
272 <div class="account-row-sync">Last sync: ${a.lastSyncFormatted}</div>
273 </div>
274 ${editBtn}
275 <button class="btn btn-sm btn-danger" onclick="GoingsOn.emails.deleteAccount('${escAttr(a.id)}')" aria-label="Delete account">&times;</button>
276 </div>
277 <div class="account-row-quick">
278 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.testAccount('${escAttr(a.id)}')">Test</button>
279 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.syncAccount('${escAttr(a.id)}', false)">Sync New</button>
280 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.syncAccount('${escAttr(a.id)}', true)">Full Sync</button>
281 </div>
282 </div>
283 `;
284 }).join('');
285 }
286
287 async function openAccountsModal() {
288 await loadAccounts();
289 const accountsList = buildAccountsListHtml(GoingsOn.state.emailAccounts);
290
291 const content = `
292 <div class="account-list">
293 ${accountsList}
294 </div>
295 <div class="form-actions">
296 <button type="button" class="btn btn-primary" onclick="GoingsOn.emails.openAddAccountModal()">+ Add Account</button>
297 <div class="form-actions-spacer"></div>
298 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
299 </div>
300 `;
301 GoingsOn.ui.openModal('Email Accounts', content);
302 }
303
304 /**
305 * Render the email-accounts management UI inline inside the settings
306 * overlay's content panel.
307 */
308 async function renderAccountsSection(container) {
309 await loadAccounts();
310 const accountsList = buildAccountsListHtml(GoingsOn.state.emailAccounts);
311 container.innerHTML = `
312 <div class="settings-section">
313 <h3 class="settings-heading">Email Accounts</h3>
314 <p class="settings-desc">Connect an email account via IMAP/SMTP to send and receive email from GoingsOn. Most providers need an app password rather than your normal password.</p>
315 <div class="account-list">
316 ${accountsList}
317 </div>
318 <div class="form-actions">
319 <button type="button" class="btn btn-primary" onclick="GoingsOn.emails.openAddAccountModal()">+ Add Account</button>
320 </div>
321 </div>
322 `;
323 }
324
325 /**
326 * Refresh whichever accounts UI is currently visible. Internal callbacks
327 * (after add/edit/delete/OAuth) use this so the settings section refreshes
328 * inline when settings is the active context, but the modal flow continues
329 * to reopen the modal when invoked from the empty-state button.
330 */
331 function refreshAccountsView() {
332 const overlay = document.getElementById('settings-overlay');
333 const settingsOpen = overlay && !overlay.classList.contains('hidden');
334 const emailSectionActive = document.querySelector('.settings-nav-item.active')?.dataset.section === 'email';
335 if (settingsOpen && emailSectionActive) {
336 const container = document.getElementById('settings-content');
337 if (container) {
338 renderAccountsSection(container);
339 GoingsOn.ui.closeModal();
340 return;
341 }
342 }
343 openAccountsModal();
344 }
345
346 async function openAddAccountModal() {
347 // First, check what OAuth providers are available
348 let oauthProviders = [];
349 try {
350 const response = await GoingsOn.api.oauth.listProviders();
351 oauthProviders = response.providers || [];
352 } catch (e) {
353 console.warn('Failed to load OAuth providers:', e);
354 }
355
356 // App-password (IMAP/SMTP) is the primary path. OAuth buttons only
357 // appear once a provider is registered (post-launch); when present they
358 // sit below the form as an alternative, not the recommended default.
359 const hasOAuth = oauthProviders.length > 0;
360
361 const imapIntro = `
362 <p class="settings-desc">Enter your account details below. Most providers (Gmail, Fastmail, iCloud, Yahoo, Outlook with 2-step verification) require an <strong>app password</strong> instead of your normal password — type your email address and GoingsOn shows the exact link to create one.</p>
363 `;
364
365 const oauthButtons = hasOAuth
366 ? `
367 <div class="imap-block-divider">
368 <div class="imap-block-title">Or connect with OAuth</div>
369 </div>
370 <div class="oauth-block">
371 <div class="oauth-buttons">
372 ${oauthProviders.map(p => `
373 <button type="button" class="btn btn-secondary" onclick="GoingsOn.emails.startOAuth('${escAttr(p.id)}')">
374 ${esc(p.name)}
375 </button>
376 `).join('')}
377 </div>
378 <div class="oauth-helptext">Signs in through your provider; no app password needed.</div>
379 </div>
380 `
381 : '';
382
383 const formHtml = buildAccountFormHtml({
384 formId: 'email-account-form',
385 idPrefix: 'acct',
386 onSubmit: "GoingsOn.emails.createAccount(event)",
387 values: {},
388 isEdit: false,
389 submitLabel: 'Add Account',
390 });
391
392 const content = `${imapIntro}${formHtml}${oauthButtons}`;
393 GoingsOn.ui.openModal('Add Email Account', content);
394 // Attach auto-detect for known providers after modal DOM is ready
395 setTimeout(() => attachAutoDetect('acct'), 0);
396 }
397
398 async function createAccount(e) {
399 e.preventDefault();
400 const form = e.target;
401
402 const syncIntervalValue = form.sync_interval_minutes.value;
403 const data = {
404 accountName: form.account_name.value,
405 emailAddress: form.email_address.value,
406 imapServer: form.imap_server.value,
407 imapPort: parseInt(form.imap_port.value),
408 smtpServer: form.smtp_server.value,
409 smtpPort: parseInt(form.smtp_port.value),
410 username: form.username.value,
411 password: form.password.value,
412 useTls: form.use_tls.checked,
413 archiveFolderName: form.archive_folder_name.value || 'Archive',
414 syncIntervalMinutes: syncIntervalValue ? parseInt(syncIntervalValue) : null,
415 };
416
417 await GoingsOn.ui.apiCall(GoingsOn.api.emailAccounts.create(data), {
418 successMessage: 'Email account added!',
419 errorMessage: 'Failed to add email account',
420 closeModal: false,
421 onSuccess: () => refreshAccountsView(),
422 });
423 }
424
425 /**
426 * Open the edit form for an existing email account.
427 * @param {string} id - Email account ID
428 */
429 async function editAccount(id) {
430 try {
431 const account = await GoingsOn.api.emailAccounts.get(id);
432 if (!account) {
433 GoingsOn.ui.showToast('Account not found', 'error');
434 return;
435 }
436
437 const content = buildAccountFormHtml({
438 formId: 'edit-email-account-form',
439 idPrefix: 'edit-acct',
440 onSubmit: `GoingsOn.emails.updateAccount(event, '${escAttr(id)}')`,
441 values: account,
442 isEdit: true,
443 submitLabel: 'Save Changes',
444 });
445
446 GoingsOn.ui.openModal('Edit Email Account', content);
447 } catch (err) {
448 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load account'), 'error');
449 }
450 }
451
452 async function updateAccount(e, id) {
453 e.preventDefault();
454 const form = e.target;
455
456 const syncIntervalValue = form.sync_interval_minutes.value;
457 const data = {
458 accountName: form.account_name.value,
459 emailAddress: form.email_address.value,
460 imapServer: form.imap_server.value,
461 imapPort: parseInt(form.imap_port.value),
462 smtpServer: form.smtp_server.value,
463 smtpPort: parseInt(form.smtp_port.value),
464 username: form.username.value,
465 password: form.password.value || null,
466 useTls: form.use_tls.checked,
467 archiveFolderName: form.archive_folder_name.value || 'Archive',
468 syncIntervalMinutes: syncIntervalValue ? parseInt(syncIntervalValue) : null,
469 };
470
471 const signature = form.email_signature.value || null;
472 const notifyNewEmails = form.notify_new_emails.checked;
473
474 await GoingsOn.ui.apiCall(GoingsOn.api.emailAccounts.update(id, data), {
475 successMessage: 'Email account updated!',
476 errorMessage: 'Failed to update email account',
477 closeModal: false,
478 onSuccess: async () => {
479 await GoingsOn.api.emailAccounts.updateSignature(id, signature);
480 await GoingsOn.api.emailAccounts.updateNotify(id, notifyNewEmails);
481 refreshAccountsView();
482 },
483 });
484 }
485
486 /**
487 * Delete an email account after confirmation.
488 * @param {string} id - Email account ID
489 */
490 async function deleteAccount(id) {
491 if (!await GoingsOn.ui.confirmDelete('email account')) return;
492
493 await GoingsOn.ui.apiCall(GoingsOn.api.emailAccounts.delete(id), {
494 successMessage: 'Email account deleted!',
495 errorMessage: 'Failed to delete email account',
496 closeModal: false,
497 onSuccess: () => refreshAccountsView(),
498 });
499 }
500
501 /**
502 * Test IMAP/SMTP connectivity for an email account and show results.
503 * @param {string} id - Email account ID
504 */
505 async function testAccount(id) {
506 GoingsOn.ui.showToast('Testing connection...', 'info');
507
508 try {
509 const result = await GoingsOn.api.emailAccounts.test(id);
510
511 // Build detailed result modal
512 const imapStatus = result.imapSuccess ? 'IMAP OK' : 'IMAP Failed: ' + result.imapMessage;
513 const smtpStatus = result.smtpSuccess ? 'SMTP OK' : 'SMTP Failed: ' + result.smtpMessage;
514
515 const foldersHtml = result.availableFolders && result.availableFolders.length > 0
516 ? `<div class="test-conn-section">
517 <div class="imap-block-title">Available Folders:</div>
518 <div class="folder-list">
519 ${result.availableFolders.map(f => esc(f)).join('<br>')}
520 </div>
521 <div class="folder-list-meta">
522 Use one of these folder names as the Archive Folder in account settings.
523 </div>
524 </div>`
525 : '';
526
527 const content = `
528 <div class="test-conn-results">
529 <div class="test-conn-result ${result.imapSuccess ? 'test-conn-result--success' : 'test-conn-result--error'}">
530 ${imapStatus}
531 </div>
532 <div class="test-conn-result ${result.smtpSuccess ? 'test-conn-result--success' : 'test-conn-result--error'}">
533 ${smtpStatus}
534 </div>
535 ${foldersHtml}
536 </div>
537 <div class="form-actions">
538 <button type="button" class="btn btn-secondary" onclick="GoingsOn.emails.refreshAccountsView()">Back</button>
539 </div>
540 `;
541 GoingsOn.ui.openModal('Connection Test Results', content);
542 } catch (err) {
543 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Connection test failed'), 'error');
544 }
545 }
546
547 /**
548 * Sync an email account (new or full) and show results.
549 * @param {string} id - Email account ID
550 * @param {boolean} [fullSync=false] - true for full re-sync, false for new-only
551 */
552 async function syncAccount(id, fullSync = false) {
553 GoingsOn.ui.showToast(fullSync ? 'Starting full sync...' : 'Starting sync...', 'info');
554
555 try {
556 const result = await GoingsOn.api.emailAccounts.sync(id, fullSync);
557
558 // Show detailed result in modal
559 const content = `
560 <div class="sync-results">
561 <div class="sync-result-banner">
562 <strong>Result:</strong> ${esc(result.message)}
563 </div>
564 <div class="sync-result-grid">
565 <div class="sync-result-tile">INBOX: ${result.inboxFetched} found</div>
566 <div class="sync-result-tile">Archive: ${result.archiveFetched} found</div>
567 </div>
568 ${result.debugInfo ? `
569 <div class="test-conn-section">
570 <div class="imap-block-title">Debug Info:</div>
571 <pre class="error-pre">${esc(result.debugInfo.split(' | ').join('\n'))}</pre>
572 </div>
573 ` : ''}
574 </div>
575 <div class="form-actions">
576 <button type="button" class="btn btn-secondary" onclick="GoingsOn.emails.refreshAccountsView()">Back</button>
577 </div>
578 `;
579 GoingsOn.ui.openModal('Sync Results', content);
580
581 // Also refresh emails if new ones were fetched
582 if (result.emailsSaved > 0) {
583 GoingsOn.emails.load();
584 }
585 } catch (err) {
586 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Sync failed'), 'error', {
587 action: { label: 'Retry', fn: () => syncAccount(id, fullSync) },
588 duration: 8000,
589 });
590 }
591 }
592
593 // ============ OAuth Flow ============
594
595 // Store OAuth state during flow
596 let pendingOAuthState = null;
597
598 /**
599 * Start the OAuth authorization flow for an email provider.
600 * @param {string} providerId - OAuth provider ID (e.g. 'fastmail', 'google')
601 */
602 async function startOAuth(providerId) {
603 try {
604 GoingsOn.ui.showToast('Starting OAuth flow...', 'info');
605
606 const result = await GoingsOn.api.oauth.start(providerId);
607
608 // Store state for verification
609 pendingOAuthState = {
610 state: result.state,
611 provider: result.provider,
612 port: result.port,
613 };
614
615 // Show waiting modal
616 showOAuthWaitingModal(result.provider);
617
618 // Open browser for authorization
619 await window.__TAURI__.shell.open(result.authUrl);
620
621 // Start listening for the callback
622 listenForOAuthCallback(result.port);
623 } catch (err) {
624 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to start OAuth'), 'error');
625 }
626 }
627
628 function showOAuthWaitingModal(provider) {
629 const providerNames = {
630 'fastmail': 'Fastmail',
631 'google': 'Google',
632 'microsoft': 'Microsoft',
633 'yahoo': 'Yahoo',
634 };
635 const displayName = providerNames[provider] || provider;
636
637 const content = `
638 <div class="oauth-waiting">
639 <div class="oauth-waiting-title">Waiting for ${displayName} authorization...</div>
640 <div class="oauth-waiting-body">
641 A browser window should have opened. Please sign in and authorize the app.
642 </div>
643 <div class="spinner oauth-waiting-spinner"></div>
644 <button type="button" class="btn btn-secondary" onclick="GoingsOn.emails.cancelOAuth()">Cancel</button>
645 </div>
646 `;
647 GoingsOn.ui.openModal('Connecting Account', content);
648 }
649
650 function cancelOAuth() {
651 pendingOAuthState = null;
652 refreshAccountsView();
653 }
654
655 async function listenForOAuthCallback(port) {
656 // Poll for callback result
657 const maxAttempts = 120; // 2 minutes
658 let attempts = 0;
659
660 const poll = async () => {
661 if (!pendingOAuthState) return; // Cancelled
662
663 attempts++;
664 if (attempts > maxAttempts) {
665 GoingsOn.ui.showToast('OAuth timeout - please try again', 'error');
666 pendingOAuthState = null;
667 refreshAccountsView();
668 return;
669 }
670
671 try {
672 // Check if there's a callback response available
673 // The callback server writes to a temp location we can poll
674 // Poll the local OAuth callback server. Expected to fail
675 // repeatedly until the user completes the browser auth flow.
676 const response = await fetch(`http://127.0.0.1:${port}/result`, {
677 method: 'GET',
678 mode: 'cors',
679 }).catch(() => null);
680
681 if (response && response.ok) {
682 const data = await response.json();
683 if (data.code) {
684 await completeOAuth(data.code, data.state);
685 return;
686 } else if (data.error) {
687 GoingsOn.ui.showToast('OAuth error: ' + data.error, 'error');
688 pendingOAuthState = null;
689 refreshAccountsView();
690 return;
691 }
692 }
693 } catch (e) {
694 // Ignore polling errors
695 }
696
697 // Continue polling
698 setTimeout(poll, 1000);
699 };
700
701 poll();
702 }
703
704 async function completeOAuth(code, state) {
705 if (!pendingOAuthState) {
706 GoingsOn.ui.showToast('OAuth session expired', 'error');
707 return;
708 }
709
710 // Verify state matches
711 if (state !== pendingOAuthState.state) {
712 GoingsOn.ui.showToast('OAuth state mismatch - possible security issue', 'error');
713 pendingOAuthState = null;
714 refreshAccountsView();
715 return;
716 }
717
718 try {
719 GoingsOn.ui.showToast('Completing authorization...', 'info');
720
721 const result = await GoingsOn.api.oauth.complete({
722 code,
723 state,
724 });
725
726 pendingOAuthState = null;
727 GoingsOn.ui.showToast(`Connected ${result.providerName} account: ${result.emailAddress}. Syncing...`, 'success');
728 refreshAccountsView();
729
730 // Auto-sync the newly connected account
731 if (result.accountId) {
732 syncAccount(result.accountId, false);
733 }
734 } catch (err) {
735 pendingOAuthState = null;
736 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to complete OAuth'), 'error');
737 refreshAccountsView();
738 }
739 }
740
741 /**
742 * Re-authorize an existing OAuth email account.
743 * @param {string} accountId - Email account ID to reconnect
744 */
745 async function reconnectOAuth(accountId) {
746 try {
747 GoingsOn.ui.showToast('Starting reconnection...', 'info');
748
749 const result = await GoingsOn.api.oauth.reconnect(accountId);
750
751 // Store state for verification
752 pendingOAuthState = {
753 state: result.state,
754 provider: result.provider,
755 port: result.port,
756 accountId: accountId, // For updating existing account
757 };
758
759 showOAuthWaitingModal(result.provider);
760 await window.__TAURI__.shell.open(result.authUrl);
761 listenForOAuthCallback(result.port);
762 } catch (err) {
763 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to start reconnection'), 'error');
764 }
765 }
766
767 // ============ Cache Helpers ============
768
769 function getAccountsCache() {
770 return GoingsOn.state.emailAccounts;
771 }
772
773 function setAccountsCache(cache) {
774 GoingsOn.state.set('emailAccounts', cache);
775 }
776
777 // ============ Extend GoingsOn.emails Namespace ============
778
779 Object.assign(GoingsOn.emails, {
780 loadAccounts,
781 openAccountsModal,
782 renderAccountsSection,
783 refreshAccountsView,
784 openAddAccountModal,
785 createAccount,
786 editAccount,
787 updateAccount,
788 deleteAccount,
789 testAccount,
790 syncAccount,
791 // OAuth
792 startOAuth,
793 cancelOAuth,
794 completeOAuth,
795 reconnectOAuth,
796 // Cache
797 getAccountsCache,
798 setAccountsCache,
799 });
800
801 })();
802