Skip to main content

max / goingson

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