Skip to content

TW-4881: Air folder counts, hardening, and Gmail-style invite card#67

Merged
qasim-nylas merged 3 commits intomainfrom
feature/TW-4881-air-folder-counts
Apr 30, 2026
Merged

TW-4881: Air folder counts, hardening, and Gmail-style invite card#67
qasim-nylas merged 3 commits intomainfrom
feature/TW-4881-air-folder-counts

Conversation

@qasim-nylas
Copy link
Copy Markdown
Collaborator

Summary

  • Air folder counts, sync recovery, and security hardening (XSS, input validation, rate limits)
  • Gmail-style calendar invitation card with raw_mime fallback for Nylas synthetic attachment IDs

Test plan

  • make ci-full passes
  • Playwright suite passes (--workers=1)
  • Verified live against a real Google grant

…ing, sync

Single-commit roll-up of the Air audit work driven by the Sent-folder bug
(folder counts wrong on real Gmail accounts) plus the surrounding
hardening uncovered along the way. Splits into three buckets:

Folder counts and per-folder coverage (the headline fix)

  * handlers_email.go: handleListEmails short-circuits the cache only
    when a folder filter is paired with a full-page hit. Partial coverage
    falls through to the API so non-Inbox folders no longer render a
    1-message stub. from/search filters keep prior behavior — they
    operate on the full cached dataset and are authoritative.
  * static/js/email-core.js: shared setFolderBadge plus
    updateActiveFolderBadge / refreshFolderBadge / refreshPrimaryFolderBadges
    so the sidebar reconciles to per-message unread instead of trusting
    Nylas's stale folder.unread_count aggregate (the SENT label on Gmail
    routinely showed 1 when 39 were truly unread).
  * static/js/email-folders.js: loadFolders fires the eager primary-folder
    badge refresh in the background so the initial paint is correct
    without waiting for a click.
  * server_sync.go: syncAccount runs syncFolders first and feeds the list
    into syncEmails, which now fans out per primary system folder
    (Inbox -> Sent -> Drafts -> Archive -> Trash -> Spam, priority order,
    custom labels skipped) via GetMessagesWithParams. Falls back to
    unfiltered top-100 on folder-API outage; per-folder failures don't
    abort the iteration; honors ctx.Err() under deadline.

Security and correctness hardening

  * cache/photos.go: validateContactID rejects path traversal on Put/Get/
    IsValid/Delete (control bytes, /, \\, .., over-length, NUL).
  * cache/cache.go, cache/encryption.go: cache files/dirs use 0750/0600.
  * handlers_bundles.go: validateBundleRule + RW-locked compiledBundleRegex
    cache prevents catastrophic-backtracking DoS on rule updates.
  * handlers_read_receipts.go: User-Agent capped at 256 bytes;
    GetTrackingPixelURL URL-escapes the email id; Cache-Control: no-store
    contract pinned by tests.
  * handlers_screener.go: IsSenderAllowed only returns true for explicit
    "allowed" status; pending senders no longer bypass screening.
  * handlers_focus_mode.go: IsFocusModeActive / ShouldAllowNotification
    honor EndsAt at call time so expired sessions don't keep silencing
    notifications.
  * handlers_scheduled_send.go: 1-year ceiling on send_at; tightened
    "too-soon" rejection.
  * handlers_email_invite.go (new): /api/emails/{id}/invite parses iCal
    payloads with strict CRLF tolerance + speakable RSVP buttons in the
    preview pane.
  * static/js/notetaker-ui.js, notetaker-actions.js: notetaker render
    pipeline routes nt.summary / meetingTitle / attendees through
    sanitizeHtml (DOMParser) plus escapeHtml on every interpolated field;
    fixes pre-existing "this.escapeHtml is not a function" + closes XSS
    via attacker-supplied subjects/HTML.
  * static/js/email-folders.js: escapeHtml uses explicit replaceAll chain
    (no .innerHTML round-trip) so attribute and element contexts are both
    safe.
  * static/js/utils.js, contacts-utils.js, email-renderer.js, shortcuts.js:
    consistent isSafeUrl / sanitizeHtml usage; templates audited.
  * static/css/calendar-grid.css: .event-relative-time pill no longer
    blocks pointer events on .event-edit-btn (z-index + pointer-events).

Server lifecycle, sync recovery, demo-mode UX

  * server.go, server_lifecycle.go, server_stores.go: deferred panic
    recovery and clean shutdown of sync goroutines; offline-queue init
    only opens the default grant.
  * server_sync.go: runSyncIteration + recoverSyncPanic isolate panics so
    one failure can't kill the loop or the process.
  * handlers_email.go: demo mode filters its dataset by
    folder/unread/starred so the sidebar exercises every system folder
    without a real account.
  * Various templates/css polish (calendar event card, header, modals)
    and a new email-invite preview block.

Tests added

  * Go: TestHandleListEmails_FolderPartialCache_FallsThroughToAPI,
    TestHandleListEmails_FolderFullCache_ShortCircuits,
    TestSyncEmails_PerFolderHydration,
    TestSyncEmails_FallsBackWhenFoldersEmpty,
    TestPrimarySystemFolderIDs_OrdersAndFilters,
    TestPrimarySystemFolderIDs_EmptyInput, plus dedicated suites for
    photos validation, cache file modes, bundle validation, focus mode,
    screener concurrency, scheduled send bounds, email invite parsing,
    and sync panic recovery.

  * Playwright: email-operations-folders (3 new badge cases),
    feature-handlers, folders-and-invite, round4-regressions,
    security-handlers, security-xss.

Verification: full Air Go suite green with -race, golangci-lint 0
issues, all email Playwright specs passing. Manually verified on a real
Gmail account: Sent badge 1 -> 39 within ~1s of opening Air; Sent list
shows real contents.
…llback

Air's email viewer fails to render calendar invitations sent through
Gmail / Nylas-managed grants. Two compounding causes:

  1. Nylas v3's single-message API surfaces inline calendar parts as
     synthetic attachments[] entries with IDs like
     "v0:base64(filename):base64(content-type):size". They look real
     but the attachments download endpoint 404s on them — the previous
     handler turned that 404 into a 5xx, hiding the card entirely.
  2. Gmail invitations frequently ship the ICS as an inline body part
     under multipart/alternative. Nylas's attachments[] list omits
     those, so the previous attachments-only detection missed them.

Fix:
- Replace the hand-rolled ICS parser with github.com/arran4/golang-ical
  for real RFC 5545 line-folding, TZID resolution, VALUE=DATE all-day
  detection, ATTENDEE list, and METHOD parsing.
- New net/mail + mime/multipart walker (handlers_email_invite_mime.go)
  finds text/calendar parts in raw_mime, including nested
  multipart/alternative and quoted-printable / base64 bodies, with
  depth + size caps.
- Handler now: try attachments[] first; if download fails OR no
  calendar in attachments[], fall through to raw_mime walker. Errors
  silently degrade to has_invite=false instead of 5xx.
- CalendarInviteResponse gains Method, Attendees, RecurrenceRule.
  New InviteAttendee carries name/email/status/role/is_organizer.
- Frontend: looksLikeInviteSubject heuristic triggers /invite for
  Gmail-style subjects ("Invitation:", "Event Invitation:",
  "Updated event:", "Canceled event:") even when attachments[] is
  empty. Card renders attendee chips with PARTSTAT colour-coding,
  "X going · Y declined" tally, and a cancellation banner that
  replaces RSVP buttons when METHOD=CANCEL.
- ensureInviteAttachmentRow injects an "invite.ics" attachment row
  in the detail pane when one isn't already rendered, mirroring
  Gmail's UX for inline calendar parts.
- Bump service worker CACHE_NAME (v1 → v2) so deployed clients
  pick up the new JS/CSS instead of stale-while-revalidate-ing
  the old assets.

MockClient gains GetMessageWithFieldsFunc so tests can pin raw_mime
responses; default behaviour preserved.

Tests:
- Go: 5 MIME walker tests (Gmail shape, nested, no-calendar,
  oversize, quoted-printable), 4 handler tests (raw_mime fallback,
  synthetic-ID fallback regression, no-calendar, real-attachment
  priority), 2 parser tests (attendees+method, cancel), inline-id
  helpers, plus existing parser tests adapted for golang-ical.
- Playwright: attendee chip + summary tally, METHOD=CANCEL banner,
  looksLikeInviteSubject matrix, ensureInviteAttachmentRow injection.

Verified live against a real Google account email where Nylas
returned a synthetic v0: attachment ID — card renders end-to-end
with title, time, organizer, attendee, and RSVP buttons.
Comment thread internal/air/static/js/notetaker-ui.js Fixed
CodeQL flagged stripEmbeddedStyles in notetaker-ui.js for incomplete
multi-character sanitization — the <style> regex is bypassable by
nested patterns like <sty<style>le>. The runtime-active copy in
notetaker-actions.js (which shadows ui.js via Object.assign load
order) and stripEmailCruft had the same vulnerability. All three now
re-parse via DOMParser and remove <style> elements / style="" attrs
through DOM operations.
@qasim-nylas qasim-nylas merged commit b032a0d into main Apr 30, 2026
6 checks passed
@qasim-nylas qasim-nylas deleted the feature/TW-4881-air-folder-counts branch April 30, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants