| 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 |
|
| 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 |
|
| 51 |
const current = values.syncIntervalMinutes != null ? String(values.syncIntervalMinutes) : ''; |
| 52 |
selected = (opt.value === current) ? 'selected' : ''; |
| 53 |
} else { |
| 54 |
|
| 55 |
selected = (opt.value === '15') ? 'selected' : ''; |
| 56 |
} |
| 57 |
return `<option value="${escAttr(opt.value)}" ${selected}>${esc(opt.label)}</option>`; |
| 58 |
}).join(''); |
| 59 |
|
| 60 |
|
| 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 |
|
| 124 |
|
| 125 |
|
| 126 |
* Well-known email provider server settings. |
| 127 |
* Key is the email domain; value has imap, smtp, archive defaults. |
| 128 |
|
| 129 |
|
| 130 |
|
| 131 |
|
| 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 & 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 |
|
| 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 |
|
| 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 |
|
| 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">×</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 |
|
| 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 |
|
| 357 |
|
| 358 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 594 |
|
| 595 |
|
| 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 |
|
| 609 |
pendingOAuthState = { |
| 610 |
state: result.state, |
| 611 |
provider: result.provider, |
| 612 |
port: result.port, |
| 613 |
}; |
| 614 |
|
| 615 |
|
| 616 |
showOAuthWaitingModal(result.provider); |
| 617 |
|
| 618 |
|
| 619 |
await window.__TAURI__.shell.open(result.authUrl); |
| 620 |
|
| 621 |
|
| 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 |
|
| 657 |
const maxAttempts = 120; |
| 658 |
let attempts = 0; |
| 659 |
|
| 660 |
const poll = async () => { |
| 661 |
if (!pendingOAuthState) return; |
| 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 |
|
| 673 |
|
| 674 |
|
| 675 |
|
| 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 |
|
| 695 |
} |
| 696 |
|
| 697 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 752 |
pendingOAuthState = { |
| 753 |
state: result.state, |
| 754 |
provider: result.provider, |
| 755 |
port: result.port, |
| 756 |
accountId: accountId, |
| 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 |
|
| 768 |
|
| 769 |
function getAccountsCache() { |
| 770 |
return GoingsOn.state.emailAccounts; |
| 771 |
} |
| 772 |
|
| 773 |
function setAccountsCache(cache) { |
| 774 |
GoingsOn.state.set('emailAccounts', cache); |
| 775 |
} |
| 776 |
|
| 777 |
|
| 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 |
|
| 792 |
startOAuth, |
| 793 |
cancelOAuth, |
| 794 |
completeOAuth, |
| 795 |
reconnectOAuth, |
| 796 |
|
| 797 |
getAccountsCache, |
| 798 |
setAccountsCache, |
| 799 |
}); |
| 800 |
|
| 801 |
})(); |
| 802 |
|