From e428e2b352d77d8b12d187561463657934ff967a Mon Sep 17 00:00:00 2001 From: Scott Carlton Date: Wed, 3 Jun 2026 20:20:51 -0600 Subject: [PATCH] feat(inbox): support Outlook email end-to-end, render HTML bodies, redesign connection settings Make the Inbox work with an Outlook connection the same way it does Gmail, render messages as real HTML, and clean up the connection settings UI. - Add a provider-agnostic email service that routes list/thread/unread/send to Gmail or Outlook based on the user's connected provider - Add a per-user Microsoft Graph mail module (list, conversation, unread, send, reply) backed by the existing per-user Outlook token helper - Fix the inbox gate: it used .maybeSingle() with no provider filter, so a user with both email and a calendar connection saw "Connect your email" - Render email bodies as sanitized HTML via DOMPurify instead of raw text, preferring the text/html part (Gmail) and HTML body (Graph) - Capture the Outlook address from the OAuth id_token, since Graph /me returns UnknownError for personal Outlook.com accounts - Redesign Connected Email/Calendar settings into per-provider cards with "Connected"/Disconnect and "+ Connect X" links; zero-state buttons unchanged Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/settings/ConnectionCard.svelte | 330 +++++++ src/lib/server/email/service.ts | 273 ++++++ src/lib/server/gmail.test.ts | 52 +- src/lib/server/gmail.ts | 33 + .../microsoft/outlook-user-mail.test.ts | 105 +++ .../microsoft/outlook-user-mail.ts | 181 ++++ .../microsoft/outlook-user.test.ts | 38 + .../integrations/microsoft/outlook-user.ts | 34 +- src/lib/test-helpers/mock-env-private.ts | 2 + src/lib/utils/sanitize-email.ts | 124 +++ src/routes/api/email/inbox/+server.ts | 62 +- src/routes/api/email/send/+server.ts | 79 +- src/routes/api/email/thread/[id]/+server.ts | 21 +- src/routes/api/email/unread/+server.ts | 13 +- src/routes/inbox/+page.server.ts | 12 +- src/routes/inbox/+page.svelte | 51 +- src/routes/settings/+page.svelte | 819 +++++++++--------- 17 files changed, 1672 insertions(+), 557 deletions(-) create mode 100644 src/lib/components/settings/ConnectionCard.svelte create mode 100644 src/lib/server/email/service.ts create mode 100644 src/lib/server/integrations/microsoft/outlook-user-mail.test.ts create mode 100644 src/lib/server/integrations/microsoft/outlook-user-mail.ts create mode 100644 src/lib/server/integrations/microsoft/outlook-user.test.ts create mode 100644 src/lib/utils/sanitize-email.ts diff --git a/src/lib/components/settings/ConnectionCard.svelte b/src/lib/components/settings/ConnectionCard.svelte new file mode 100644 index 00000000..7c8d61bf --- /dev/null +++ b/src/lib/components/settings/ConnectionCard.svelte @@ -0,0 +1,330 @@ + + +
+
+ + {#if provider === 'gmail'} + + + + + + + + {:else if provider === 'outlook'} + + + + + + + + + + + + + + + + + + + {:else if provider === 'google_calendar'} + + + + + + + + + + + {:else if provider === 'microsoft_calendar'} + + + + + + + + + + + + + + + + + + + {:else if provider === 'calendly'} + + + + + + + {/if} + +
+

{name}

+ {#if detail} +

{detail}

+ {/if} +
+
+
+ + + Connected + + +
+
diff --git a/src/lib/server/email/service.ts b/src/lib/server/email/service.ts new file mode 100644 index 00000000..cd11c3cb --- /dev/null +++ b/src/lib/server/email/service.ts @@ -0,0 +1,273 @@ +import { + getGmailClient, + parseMessage, + extractHtmlBody, + buildRawEmail, + type EmailAttachment +} from '$lib/server/gmail'; +import { + listUserMessages, + getUserConversation, + getUserUnreadCount, + sendUserMail, + replyUserMail +} from '$lib/server/integrations/microsoft/outlook-user-mail'; +import { supabaseAdmin } from '$lib/server/supabase'; + +export type EmailProvider = 'gmail' | 'outlook'; + +export type InboxMessage = { + id: string; + threadId: string; + from: string; + to: string; + subject: string; + snippet: string; + date: string; + isUnread: boolean; +}; + +export type ThreadMessage = { + id: string; + from: string; + to: string; + subject: string; + date: string; + /** Raw body — HTML when bodyIsHtml is true, otherwise plain text. Sanitized client-side. */ + body: string; + bodyIsHtml: boolean; +}; + +export type ListInboxOptions = { + q?: string; + /** + * Restrict to messages from/to these addresses. + * `null`/`undefined` → no contact filter (full inbox). + * `[]` → a filter is active but resolved to zero contacts → return nothing. + */ + contactEmails?: string[] | null; +}; + +export type SendEmailInput = { + to: string; + subject: string; + body: string; + /** Provider thread id (Gmail threadId / Outlook conversationId) for in-thread replies. */ + threadId?: string; + /** Original message id to reply to — used by Outlook for proper threading. */ + inReplyTo?: string; + attachments?: { filename: string; mimeType: string; content: string }[]; +}; + +/** Resolve which email provider this user has connected (gmail preferred if both somehow exist). */ +export async function getConnectedEmailProvider( + profileId: string +): Promise<{ provider: EmailProvider; email: string } | null> { + const { data } = await supabaseAdmin + .from('email_connections') + .select('provider, email_address') + .eq('profile_id', profileId) + .in('provider', ['gmail', 'outlook']); + + const rows = (data ?? []) as { provider: EmailProvider; email_address: string }[]; + if (rows.length === 0) return null; + + const chosen = rows.find((r) => r.provider === 'gmail') ?? rows[0]; + return { provider: chosen.provider, email: chosen.email_address }; +} + +function extractEmail(header: string): string { + const match = header.match(/<(.+?)>/); + return (match ? match[1] : header).trim().toLowerCase(); +} + +export async function listInbox( + profileId: string, + opts: ListInboxOptions = {} +): Promise { + const conn = await getConnectedEmailProvider(profileId); + if (!conn) return []; + return conn.provider === 'gmail' + ? listGmailInbox(profileId, opts) + : listOutlookInbox(profileId, opts); +} + +async function listGmailInbox(profileId: string, { q, contactEmails }: ListInboxOptions) { + const gmail = await getGmailClient(profileId); + if (!gmail) return []; + + let searchQuery = ''; + if (contactEmails !== null && contactEmails !== undefined) { + if (contactEmails.length === 0) return []; + const fromClauses = contactEmails.map((e) => `from:${e}`).join(' OR '); + const toClauses = contactEmails.map((e) => `to:${e}`).join(' OR '); + searchQuery = `(${fromClauses} OR ${toClauses})`; + } + if (q) searchQuery = searchQuery ? `${searchQuery} ${q}` : q; + + const listRes = await gmail.users.messages.list({ + userId: 'me', + q: searchQuery || 'in:inbox', + maxResults: 30 + }); + + const messageIds = listRes.data.messages ?? []; + return Promise.all( + messageIds.map(async (m) => { + const msg = await gmail.users.messages.get({ + userId: 'me', + id: m.id!, + format: 'metadata', + metadataHeaders: ['From', 'To', 'Subject', 'Date'] + }); + const p = parseMessage(msg.data); + return { + id: p.id, + threadId: p.threadId, + from: p.from, + to: p.to, + subject: p.subject, + snippet: p.snippet, + date: p.date, + isUnread: p.isUnread + }; + }) + ); +} + +async function listOutlookInbox(profileId: string, { q, contactEmails }: ListInboxOptions) { + if (contactEmails !== null && contactEmails !== undefined && contactEmails.length === 0) { + return []; + } + + const raw = await listUserMessages(profileId, { top: 50 }); + let messages: InboxMessage[] = raw.map((m) => ({ + id: m.id, + threadId: m.conversationId, + from: m.from, + to: m.to, + subject: m.subject, + snippet: m.bodyPreview, + date: m.receivedDateTime, + isUnread: !m.isRead + })); + + if (contactEmails && contactEmails.length > 0) { + const set = new Set(contactEmails.map((e) => e.toLowerCase())); + messages = messages.filter((m) => set.has(extractEmail(m.from)) || set.has(extractEmail(m.to))); + } + + if (q) { + const needle = q.toLowerCase(); + messages = messages.filter( + (m) => + m.subject.toLowerCase().includes(needle) || + m.from.toLowerCase().includes(needle) || + m.snippet.toLowerCase().includes(needle) + ); + } + + return messages.slice(0, 30); +} + +export async function getThread(profileId: string, threadId: string): Promise { + const conn = await getConnectedEmailProvider(profileId); + if (!conn) return []; + + if (conn.provider === 'gmail') { + const gmail = await getGmailClient(profileId); + if (!gmail) return []; + const thread = await gmail.users.threads.get({ userId: 'me', id: threadId, format: 'full' }); + return (thread.data.messages ?? []).map((m) => { + const p = parseMessage(m); + const { content, isHtml } = extractHtmlBody(m.payload ?? undefined); + return { + id: p.id, + from: p.from, + to: p.to, + subject: p.subject, + date: p.date, + body: content, + bodyIsHtml: isHtml + }; + }); + } + + const conversation = await getUserConversation(profileId, threadId); + return conversation.map((m) => { + const isHtml = !!m.body && m.bodyContentType === 'html'; + return { + id: m.id, + from: m.from, + to: m.to, + subject: m.subject, + date: m.receivedDateTime, + body: m.body ?? m.bodyPreview, + bodyIsHtml: isHtml + }; + }); +} + +export async function getUnreadCount(profileId: string): Promise { + const conn = await getConnectedEmailProvider(profileId); + if (!conn) return 0; + + if (conn.provider === 'gmail') { + const gmail = await getGmailClient(profileId); + if (!gmail) return 0; + const label = await gmail.users.labels.get({ userId: 'me', id: 'INBOX' }); + return label.data.messagesUnread ?? 0; + } + + return getUserUnreadCount(profileId); +} + +export async function sendEmail( + profileId: string, + input: SendEmailInput +): Promise<{ messageId: string; threadId: string | null }> { + const conn = await getConnectedEmailProvider(profileId); + if (!conn) throw new Error('Email not connected'); + + if (conn.provider === 'gmail') { + const gmail = await getGmailClient(profileId); + if (!gmail) throw new Error('Email not connected'); + + let emailAttachments: EmailAttachment[] | undefined; + if (input.attachments?.length) { + emailAttachments = input.attachments.map((a) => ({ + filename: a.filename, + mimeType: a.mimeType, + content: Buffer.from(a.content, 'base64') + })); + } + + const raw = buildRawEmail( + conn.email, + input.to, + input.subject, + input.body, + input.threadId, + emailAttachments + ); + const res = await gmail.users.messages.send({ + userId: 'me', + requestBody: { raw, threadId: input.threadId || undefined } + }); + return { messageId: res.data.id ?? '', threadId: res.data.threadId ?? null }; + } + + // Outlook: reply in-thread when we have the original message id, otherwise send fresh. + if (input.inReplyTo) { + await replyUserMail(profileId, input.inReplyTo, input.body); + return { messageId: '', threadId: input.threadId ?? null }; + } + + await sendUserMail(profileId, { + to: input.to, + subject: input.subject, + body: input.body, + attachments: input.attachments + }); + return { messageId: '', threadId: null }; +} diff --git a/src/lib/server/gmail.test.ts b/src/lib/server/gmail.test.ts index 9078a2fe..b476191a 100644 --- a/src/lib/server/gmail.test.ts +++ b/src/lib/server/gmail.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseMessage, buildRawEmail } from './gmail.js'; +import { parseMessage, buildRawEmail, extractHtmlBody } from './gmail.js'; function decodeBase64Url(encoded: string): string { const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); @@ -163,6 +163,56 @@ describe('parseMessage', () => { }); }); +describe('extractHtmlBody', () => { + it('prefers the text/html part and flags it as HTML', () => { + const payload = { + mimeType: 'multipart/alternative', + parts: [ + { mimeType: 'text/plain', body: { data: encodeBase64Url('plain version') } }, + { mimeType: 'text/html', body: { data: encodeBase64Url('

html version

') } } + ] + }; + const result = extractHtmlBody(payload); + expect(result.isHtml).toBe(true); + expect(result.content).toBe('

html version

'); + }); + + it('falls back to text/plain when no HTML part exists', () => { + const payload = { + mimeType: 'multipart/alternative', + parts: [{ mimeType: 'text/plain', body: { data: encodeBase64Url('only plain') } }] + }; + const result = extractHtmlBody(payload); + expect(result.isHtml).toBe(false); + expect(result.content).toBe('only plain'); + }); + + it('recurses into nested multipart structures to find HTML', () => { + const payload = { + mimeType: 'multipart/mixed', + parts: [ + { + mimeType: 'multipart/alternative', + parts: [{ mimeType: 'text/html', body: { data: encodeBase64Url('nested') } }] + } + ] + }; + const result = extractHtmlBody(payload); + expect(result.isHtml).toBe(true); + expect(result.content).toBe('nested'); + }); + + it('uses a single-part body when there are no parts', () => { + const payload = { mimeType: 'text/plain', body: { data: encodeBase64Url('single part') } }; + const result = extractHtmlBody(payload); + expect(result).toEqual({ content: 'single part', isHtml: false }); + }); + + it('returns empty for an undefined payload', () => { + expect(extractHtmlBody(undefined)).toEqual({ content: '', isHtml: false }); + }); +}); + describe('buildRawEmail', () => { it('builds a simple email without attachments', () => { const raw = buildRawEmail('from@test.com', 'to@test.com', 'Hello', 'Body text'); diff --git a/src/lib/server/gmail.ts b/src/lib/server/gmail.ts index c504ddde..63ca8079 100644 --- a/src/lib/server/gmail.ts +++ b/src/lib/server/gmail.ts @@ -122,6 +122,39 @@ export function parseMessage(msg: gmail_v1.Schema$Message): GmailMessage { }; } +function findPartByMime(payload: gmail_v1.Schema$MessagePart, mime: string): string | null { + if (payload.mimeType === mime && payload.body?.data) { + return decodeBase64Url(payload.body.data); + } + if (payload.parts) { + for (const part of payload.parts) { + const found = findPartByMime(part, mime); + if (found !== null) return found; + } + } + return null; +} + +/** + * Extract a message body for rich display, preferring the HTML part so the + * inbox can render it like a real mail client. Falls back to text/plain. + */ +export function extractHtmlBody(payload: gmail_v1.Schema$MessagePart | undefined): { + content: string; + isHtml: boolean; +} { + if (!payload) return { content: '', isHtml: false }; + + const html = findPartByMime(payload, 'text/html'); + if (html !== null) return { content: html, isHtml: true }; + + const text = findPartByMime(payload, 'text/plain'); + if (text !== null) return { content: text, isHtml: false }; + + if (payload.body?.data) return { content: decodeBase64Url(payload.body.data), isHtml: false }; + return { content: '', isHtml: false }; +} + export type EmailAttachment = { filename: string; mimeType: string; diff --git a/src/lib/server/integrations/microsoft/outlook-user-mail.test.ts b/src/lib/server/integrations/microsoft/outlook-user-mail.test.ts new file mode 100644 index 00000000..52f24266 --- /dev/null +++ b/src/lib/server/integrations/microsoft/outlook-user-mail.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { mapGraphMessage, parseRecipients } from './outlook-user-mail.js'; + +describe('mapGraphMessage', () => { + it('formats from/to with a display name as "Name
"', () => { + const result = mapGraphMessage({ + id: 'AAQk-1', + conversationId: 'conv-1', + subject: 'Fall order', + from: { emailAddress: { name: 'Alice Buyer', address: 'alice@shop.com' } }, + toRecipients: [{ emailAddress: { name: 'Acme Sales', address: 'sales@acme.com' } }], + receivedDateTime: '2026-06-01T12:00:00Z', + bodyPreview: 'Here is my order', + isRead: false + }); + + expect(result.id).toBe('AAQk-1'); + expect(result.conversationId).toBe('conv-1'); + expect(result.subject).toBe('Fall order'); + expect(result.from).toBe('Alice Buyer '); + expect(result.to).toBe('Acme Sales '); + expect(result.bodyPreview).toBe('Here is my order'); + expect(result.isRead).toBe(false); + }); + + it('uses the bare address when no display name is present', () => { + const result = mapGraphMessage({ + id: 'm2', + conversationId: 'c2', + from: { emailAddress: { address: 'bob@shop.com' } }, + receivedDateTime: '2026-06-02T08:00:00Z' + }); + + expect(result.from).toBe('bob@shop.com'); + expect(result.to).toBe(''); + }); + + it('does not duplicate the address when name equals address', () => { + const result = mapGraphMessage({ + id: 'm3', + from: { emailAddress: { name: 'bob@shop.com', address: 'bob@shop.com' } }, + receivedDateTime: '2026-06-02T08:00:00Z' + }); + + expect(result.from).toBe('bob@shop.com'); + }); + + it('falls back to the message id when conversationId is missing', () => { + const result = mapGraphMessage({ + id: 'm4', + receivedDateTime: '2026-06-02T08:00:00Z' + }); + + expect(result.conversationId).toBe('m4'); + }); + + it('defaults isRead to true and leaves body undefined when absent', () => { + const result = mapGraphMessage({ + id: 'm5', + receivedDateTime: '2026-06-02T08:00:00Z' + }); + + expect(result.isRead).toBe(true); + expect(result.body).toBeUndefined(); + expect(result.subject).toBe(''); + }); + + it('captures an HTML body content type', () => { + const result = mapGraphMessage({ + id: 'm6', + receivedDateTime: '2026-06-02T08:00:00Z', + body: { contentType: 'html', content: '

hi

' } + }); + + expect(result.body).toBe('

hi

'); + expect(result.bodyContentType).toBe('html'); + }); + + it('normalizes a non-html content type to text', () => { + const result = mapGraphMessage({ + id: 'm7', + receivedDateTime: '2026-06-02T08:00:00Z', + body: { contentType: 'text', content: 'plain' } + }); + + expect(result.bodyContentType).toBe('text'); + }); +}); + +describe('parseRecipients', () => { + it('splits a comma-separated list into Graph recipient objects', () => { + expect(parseRecipients('a@x.com, b@y.com')).toEqual([ + { emailAddress: { address: 'a@x.com' } }, + { emailAddress: { address: 'b@y.com' } } + ]); + }); + + it('trims whitespace and drops empty entries', () => { + expect(parseRecipients(' a@x.com ,, ')).toEqual([{ emailAddress: { address: 'a@x.com' } }]); + }); + + it('returns an empty array for an empty string', () => { + expect(parseRecipients('')).toEqual([]); + }); +}); diff --git a/src/lib/server/integrations/microsoft/outlook-user-mail.ts b/src/lib/server/integrations/microsoft/outlook-user-mail.ts new file mode 100644 index 00000000..b3f651a9 --- /dev/null +++ b/src/lib/server/integrations/microsoft/outlook-user-mail.ts @@ -0,0 +1,181 @@ +import { getOutlookUserToken } from './outlook-user.js'; + +const GRAPH = 'https://graph.microsoft.com/v1.0'; + +export type OutlookUserMessage = { + id: string; + conversationId: string; + subject: string; + /** "Display Name
" when a name is present, otherwise the bare address. */ + from: string; + to: string; + receivedDateTime: string; + bodyPreview: string; + isRead: boolean; + body?: string; + bodyContentType?: 'html' | 'text'; +}; + +type GraphAddress = { emailAddress?: { name?: string | null; address?: string | null } | null }; + +type GraphMessage = { + id: string; + conversationId?: string | null; + subject?: string | null; + from?: GraphAddress | null; + toRecipients?: GraphAddress[] | null; + receivedDateTime: string; + bodyPreview?: string | null; + isRead?: boolean | null; + body?: { contentType?: string | null; content?: string | null } | null; +}; + +/** Format a Graph address as "Name
" so the client's From-header parser can split it. */ +function formatAddress(addr?: GraphAddress | null): string { + const email = addr?.emailAddress?.address ?? ''; + const name = addr?.emailAddress?.name ?? ''; + if (name && name !== email) return `${name} <${email}>`; + return email; +} + +/** Map a raw Graph message into our normalized shape. Pure — exported for testing. */ +export function mapGraphMessage(msg: GraphMessage): OutlookUserMessage { + return { + id: msg.id, + conversationId: msg.conversationId ?? msg.id, + subject: msg.subject ?? '', + from: formatAddress(msg.from), + to: formatAddress(msg.toRecipients?.[0]), + receivedDateTime: msg.receivedDateTime, + bodyPreview: msg.bodyPreview ?? '', + isRead: msg.isRead ?? true, + body: msg.body?.content ?? undefined, + bodyContentType: msg.body?.contentType === 'html' ? 'html' : 'text' + }; +} + +/** Split a comma-separated recipient list into Graph recipient objects. Pure — exported for testing. */ +export function parseRecipients(to: string): { emailAddress: { address: string } }[] { + return to + .split(',') + .map((e) => e.trim()) + .filter(Boolean) + .map((address) => ({ emailAddress: { address } })); +} + +async function graphUserFetch( + profileId: string, + path: string, + options?: RequestInit +): Promise { + const token = await getOutlookUserToken(profileId); + if (!token) return null; + + const res = await fetch(`${GRAPH}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...options?.headers + } + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(`Graph API error: ${err.error?.message ?? res.statusText}`); + } + + // sendMail / reply return 202 Accepted with no body. + if (res.status === 202 || res.status === 204) return null; + + const text = await res.text(); + return text ? (JSON.parse(text) as T) : null; +} + +export async function listUserMessages( + profileId: string, + options?: { top?: number; folder?: string } +): Promise { + const top = options?.top ?? 30; + const folder = options?.folder ?? 'inbox'; + + const data = await graphUserFetch<{ value: GraphMessage[] }>( + profileId, + `/me/mailFolders/${folder}/messages?$top=${top}&$orderby=receivedDateTime desc&$select=id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview,isRead` + ); + + return (data?.value ?? []).map(mapGraphMessage); +} + +export async function getUserConversation( + profileId: string, + conversationId: string +): Promise { + const params = new URLSearchParams(); + params.set('$filter', `conversationId eq '${conversationId.replace(/'/g, "''")}'`); + params.set( + '$select', + 'id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview,body,isRead' + ); + + // Graph returns HTML bodies by default; the thread view sanitizes and renders + // them as rich HTML, so no Prefer header is needed. + const data = await graphUserFetch<{ value: GraphMessage[] }>( + profileId, + `/me/messages?${params.toString()}` + ); + + // Graph rejects $orderby alongside a conversationId $filter, so sort oldest-first here. + return (data?.value ?? []) + .map(mapGraphMessage) + .sort((a, b) => a.receivedDateTime.localeCompare(b.receivedDateTime)); +} + +export async function getUserUnreadCount(profileId: string): Promise { + const data = await graphUserFetch<{ unreadItemCount?: number }>( + profileId, + '/me/mailFolders/inbox?$select=unreadItemCount' + ); + return data?.unreadItemCount ?? 0; +} + +export async function sendUserMail( + profileId: string, + input: { + to: string; + subject: string; + body: string; + attachments?: { filename: string; mimeType: string; content: string }[]; + } +): Promise { + const message: Record = { + subject: input.subject, + body: { contentType: 'Text', content: input.body }, + toRecipients: parseRecipients(input.to) + }; + + if (input.attachments?.length) { + message.attachments = input.attachments.map((a) => ({ + '@odata.type': '#microsoft.graph.fileAttachment', + name: a.filename, + contentType: a.mimeType, + contentBytes: a.content + })); + } + + await graphUserFetch(profileId, '/me/sendMail', { + method: 'POST', + body: JSON.stringify({ message, saveToSentItems: true }) + }); +} + +export async function replyUserMail( + profileId: string, + messageId: string, + body: string +): Promise { + await graphUserFetch(profileId, `/me/messages/${encodeURIComponent(messageId)}/reply`, { + method: 'POST', + body: JSON.stringify({ comment: body }) + }); +} diff --git a/src/lib/server/integrations/microsoft/outlook-user.test.ts b/src/lib/server/integrations/microsoft/outlook-user.test.ts new file mode 100644 index 00000000..bf8134ea --- /dev/null +++ b/src/lib/server/integrations/microsoft/outlook-user.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { emailFromIdToken } from './outlook-user.js'; + +function makeIdToken(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); + return `${header}.${payload}.signature`; +} + +describe('emailFromIdToken', () => { + it('returns the email claim when present', () => { + expect(emailFromIdToken(makeIdToken({ email: 'scott@threadline.systems' }))).toBe( + 'scott@threadline.systems' + ); + }); + + it('falls back to preferred_username when email is absent', () => { + expect(emailFromIdToken(makeIdToken({ preferred_username: 'user@outlook.com' }))).toBe( + 'user@outlook.com' + ); + }); + + it('prefers email over preferred_username', () => { + const token = makeIdToken({ email: 'a@x.com', preferred_username: 'b@y.com' }); + expect(emailFromIdToken(token)).toBe('a@x.com'); + }); + + it('returns null when no email-like claim exists', () => { + expect(emailFromIdToken(makeIdToken({ sub: '123' }))).toBeNull(); + }); + + it('returns null for undefined, null, or malformed tokens', () => { + expect(emailFromIdToken(undefined)).toBeNull(); + expect(emailFromIdToken(null)).toBeNull(); + expect(emailFromIdToken('not-a-jwt')).toBeNull(); + expect(emailFromIdToken('only.two')).toBeNull(); + }); +}); diff --git a/src/lib/server/integrations/microsoft/outlook-user.ts b/src/lib/server/integrations/microsoft/outlook-user.ts index be00f97b..575442a1 100644 --- a/src/lib/server/integrations/microsoft/outlook-user.ts +++ b/src/lib/server/integrations/microsoft/outlook-user.ts @@ -44,18 +44,44 @@ export async function exchangeOutlookCode( throw new Error(`Microsoft OAuth error: ${data.error_description ?? data.error}`); } - const profile = await fetch('https://graph.microsoft.com/v1.0/me', { - headers: { Authorization: `Bearer ${data.access_token}` } - }).then((r) => r.json()); + // Prefer the id_token's email claim — Graph /me returns UnknownError for + // personal Outlook.com accounts, so it can't be relied on for the address. + let email = emailFromIdToken(data.id_token) ?? ''; + if (!email) { + try { + const profile = await fetch('https://graph.microsoft.com/v1.0/me', { + headers: { Authorization: `Bearer ${data.access_token}` } + }).then((r) => r.json()); + email = (profile.mail ?? profile.userPrincipalName ?? '') as string; + } catch { + email = ''; + } + } return { accessToken: data.access_token as string, refreshToken: (data.refresh_token ?? '') as string, expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(), - email: (profile.mail ?? profile.userPrincipalName ?? '') as string + email }; } +/** Extract the email/UPN claim from an OIDC id_token (JWT). Returns null if absent. */ +export function emailFromIdToken(idToken: string | undefined | null): string | null { + if (!idToken) return null; + const payload = idToken.split('.')[1]; + if (!payload) return null; + try { + const json = Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString( + 'utf-8' + ); + const claims = JSON.parse(json) as { email?: string; preferred_username?: string }; + return claims.email ?? claims.preferred_username ?? null; + } catch { + return null; + } +} + export async function getOutlookUserToken(profileId: string): Promise { const { data: connection } = await supabaseAdmin .from('email_connections') diff --git a/src/lib/test-helpers/mock-env-private.ts b/src/lib/test-helpers/mock-env-private.ts index ee78d9d8..db31803a 100644 --- a/src/lib/test-helpers/mock-env-private.ts +++ b/src/lib/test-helpers/mock-env-private.ts @@ -2,6 +2,8 @@ export const SUPABASE_SERVICE_ROLE_KEY = 'test-service-role-key'; export const GOOGLE_CLIENT_ID = 'test-google-client-id'; export const GOOGLE_CLIENT_SECRET = 'test-google-client-secret'; export const GOOGLE_REDIRECT_URI = 'http://localhost/auth/callback'; +export const MICROSOFT_CLIENT_ID = 'test-microsoft-client-id'; +export const MICROSOFT_CLIENT_SECRET = 'test-microsoft-client-secret'; export const ANTHROPIC_API_KEY = 'test-anthropic-key'; export const ELEVENLABS_API_KEY = 'test-elevenlabs-key'; export const TWILIO_ACCOUNT_SID = 'test-twilio-sid'; diff --git a/src/lib/utils/sanitize-email.ts b/src/lib/utils/sanitize-email.ts new file mode 100644 index 00000000..e2211840 --- /dev/null +++ b/src/lib/utils/sanitize-email.ts @@ -0,0 +1,124 @@ +import DOMPurify from 'dompurify'; + +// Email bodies are far richer than chat markdown — they use tables for layout, +// inline styles, images, and font tags. Allow that subset while still stripping +// scripts, event handlers, iframes, forms, etc. +const ALLOWED_TAGS = [ + 'a', + 'b', + 'i', + 'u', + 's', + 'em', + 'strong', + 'small', + 'sub', + 'sup', + 'p', + 'br', + 'hr', + 'span', + 'div', + 'blockquote', + 'pre', + 'code', + 'ul', + 'ol', + 'li', + 'dl', + 'dt', + 'dd', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'table', + 'thead', + 'tbody', + 'tfoot', + 'tr', + 'td', + 'th', + 'caption', + 'col', + 'colgroup', + 'img', + 'figure', + 'figcaption', + 'center', + 'font' +]; + +const ALLOWED_ATTR = [ + 'href', + 'title', + 'target', + 'rel', + 'src', + 'alt', + 'width', + 'height', + 'align', + 'valign', + 'bgcolor', + 'color', + 'face', + 'size', + 'style', + 'colspan', + 'rowspan', + 'cellpadding', + 'cellspacing', + 'border', + 'dir', + 'lang' +]; + +// http(s)/mailto/tel for links, plus inline data:image for embedded images. +const ALLOWED_URI_REGEXP = + /^(?:(?:https?|mailto|tel):|data:image\/(?:png|jpe?g|gif|webp|svg\+xml);)/i; + +let hookAdded = false; +function ensureHook() { + if (hookAdded || typeof window === 'undefined') return; + // Force every link to open in a new tab and never leak the opener / referrer. + DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'A' && node.getAttribute('href')) { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer nofollow'); + } + if (node.tagName === 'IMG') { + node.setAttribute('loading', 'lazy'); + } + }); + hookAdded = true; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Sanitize a raw HTML email body for safe rendering with {@html}. */ +export function sanitizeEmailHtml(html: string): string { + // DOMPurify needs a DOM; on the server return nothing — the body renders + // client-side once the thread is fetched, so this is never user-visible. + if (typeof window === 'undefined') return ''; + ensureHook(); + return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR, ALLOWED_URI_REGEXP }); +} + +/** + * Render an email body for display. HTML bodies are sanitized; plain-text + * bodies are escaped and wrapped so their whitespace and line breaks survive. + */ +export function renderEmailBody(content: string, isHtml: boolean): string { + if (!content) return ''; + if (isHtml) return sanitizeEmailHtml(content); + return `
${escapeHtml(content)}
`; +} diff --git a/src/routes/api/email/inbox/+server.ts b/src/routes/api/email/inbox/+server.ts index f41483d7..bef72f19 100644 --- a/src/routes/api/email/inbox/+server.ts +++ b/src/routes/api/email/inbox/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getGmailClient, parseMessage } from '$lib/server/gmail'; +import { listInbox } from '$lib/server/email/service'; import { supabaseAdmin } from '$lib/server/supabase'; export const GET: RequestHandler = async ({ url, locals }) => { @@ -8,22 +8,14 @@ export const GET: RequestHandler = async ({ url, locals }) => { return json({ error: 'Unauthorized' }, { status: 401 }); } - const gmail = await getGmailClient(locals.user.id); - if (!gmail) { - return json({ messages: [], nextPageToken: undefined }); - } - const q = url.searchParams.get('q') ?? ''; const filter = url.searchParams.get('filter') ?? 'all'; - const pageToken = url.searchParams.get('pageToken') ?? undefined; const orgId = locals.organization?.id; - let searchQuery = ''; - - // For filtered views (accounts/brands), restrict to known contacts - // For 'all', show full inbox without filtering by contacts + // Resolve the contact set for filtered views. `null` means "no contact filter" (full inbox). + let contactEmails: string[] | null = null; if (orgId && filter !== 'all') { - const emailAddresses: string[] = []; + contactEmails = []; if (filter === 'accounts') { const { data: accounts } = await supabaseAdmin @@ -31,7 +23,7 @@ export const GET: RequestHandler = async ({ url, locals }) => { .select('contact_email') .eq('organization_id', orgId) .not('contact_email', 'is', null); - if (accounts) emailAddresses.push(...accounts.map((a) => a.contact_email!).filter(Boolean)); + if (accounts) contactEmails.push(...accounts.map((a) => a.contact_email!).filter(Boolean)); } if (filter === 'brands') { @@ -40,51 +32,15 @@ export const GET: RequestHandler = async ({ url, locals }) => { .select('contact_email') .eq('organization_id', orgId) .not('contact_email', 'is', null); - if (brands) emailAddresses.push(...brands.map((b) => b.contact_email!).filter(Boolean)); - } - - if (emailAddresses.length > 0) { - const fromClauses = emailAddresses.map((e) => `from:${e}`).join(' OR '); - const toClauses = emailAddresses.map((e) => `to:${e}`).join(' OR '); - searchQuery = `(${fromClauses} OR ${toClauses})`; - } else { - // No contacts found for this filter — return empty - return json({ messages: [], nextPageToken: undefined }); + if (brands) contactEmails.push(...brands.map((b) => b.contact_email!).filter(Boolean)); } } - if (q) { - searchQuery = searchQuery ? `${searchQuery} ${q}` : q; - } - try { - const listRes = await gmail.users.messages.list({ - userId: 'me', - q: searchQuery || 'in:inbox', - maxResults: 30, - pageToken - }); - - const messageIds = listRes.data.messages ?? []; - - const messages = await Promise.all( - messageIds.map(async (m) => { - const msg = await gmail.users.messages.get({ - userId: 'me', - id: m.id!, - format: 'metadata', - metadataHeaders: ['From', 'To', 'Subject', 'Date'] - }); - return parseMessage(msg.data); - }) - ); - - return json({ - messages, - nextPageToken: listRes.data.nextPageToken ?? undefined - }); + const messages = await listInbox(locals.user.id, { q: q || undefined, contactEmails }); + return json({ messages }); } catch (err) { - console.error('Gmail inbox error:', err); + console.error('Inbox fetch error:', err); return json({ messages: [], error: 'Failed to fetch inbox' }); } }; diff --git a/src/routes/api/email/send/+server.ts b/src/routes/api/email/send/+server.ts index 431a8736..8a1fce8d 100644 --- a/src/routes/api/email/send/+server.ts +++ b/src/routes/api/email/send/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getGmailClient, buildRawEmail, type EmailAttachment } from '$lib/server/gmail'; +import { sendEmail } from '$lib/server/email/service'; import { supabaseAdmin } from '$lib/server/supabase'; export const POST: RequestHandler = async ({ request, locals }) => { @@ -8,68 +8,29 @@ export const POST: RequestHandler = async ({ request, locals }) => { return json({ error: 'Unauthorized' }, { status: 401 }); } - const gmail = await getGmailClient(locals.user.id); - if (!gmail) { - return json({ error: 'Gmail not connected' }, { status: 400 }); - } - - const { - to, - subject, - body, - threadId, - relatedType, - relatedId, - attachments: rawAttachments - } = await request.json(); + const { to, subject, body, threadId, inReplyTo, relatedType, relatedId, attachments } = + await request.json(); if (!to || !subject || !body) { return json({ error: 'Missing required fields: to, subject, body' }, { status: 400 }); } - // Get user's connected email address - const { data: connection } = await supabaseAdmin - .from('email_connections') - .select('email_address') - .eq('profile_id', locals.user.id) - .eq('provider', 'gmail') - .single(); - - if (!connection) { - return json({ error: 'Gmail not connected' }, { status: 400 }); - } - - // Convert base64 attachment content to Buffers - let emailAttachments: EmailAttachment[] | undefined; - if (rawAttachments && Array.isArray(rawAttachments) && rawAttachments.length > 0) { - emailAttachments = rawAttachments.map( - (a: { filename: string; mimeType: string; content: string }) => ({ - filename: a.filename, - mimeType: a.mimeType, - content: Buffer.from(a.content, 'base64') - }) - ); + let result: { messageId: string; threadId: string | null }; + try { + result = await sendEmail(locals.user.id, { + to, + subject, + body, + threadId, + inReplyTo, + attachments + }); + } catch (err) { + console.error('Email send error:', err); + const message = err instanceof Error ? err.message : 'Failed to send email'; + return json({ error: message, message }, { status: 400 }); } - const raw = buildRawEmail( - connection.email_address, - to, - subject, - body, - threadId, - emailAttachments - ); - - const sendResult = await gmail.users.messages.send({ - userId: 'me', - requestBody: { - raw, - threadId: threadId || undefined - } - }); - - const messageId = sendResult.data.id ?? ''; - // Log to email_log table if (locals.organization) { await supabaseAdmin.from('email_log').insert({ @@ -78,12 +39,12 @@ export const POST: RequestHandler = async ({ request, locals }) => { to_email: to, subject, body, - gmail_message_id: messageId, - gmail_thread_id: sendResult.data.threadId ?? null, + gmail_message_id: result.messageId || null, + gmail_thread_id: result.threadId, related_type: relatedType ?? null, related_id: relatedId ?? null }); } - return json({ success: true, messageId }); + return json({ success: true, messageId: result.messageId }); }; diff --git a/src/routes/api/email/thread/[id]/+server.ts b/src/routes/api/email/thread/[id]/+server.ts index 7360234f..280ff9e9 100644 --- a/src/routes/api/email/thread/[id]/+server.ts +++ b/src/routes/api/email/thread/[id]/+server.ts @@ -1,24 +1,17 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getGmailClient, parseMessage } from '$lib/server/gmail'; +import { getThread } from '$lib/server/email/service'; export const GET: RequestHandler = async ({ params, locals }) => { if (!locals.session || !locals.user) { return json({ error: 'Unauthorized' }, { status: 401 }); } - const gmail = await getGmailClient(locals.user.id); - if (!gmail) { - return json({ error: 'Gmail not connected' }, { status: 400 }); + try { + const messages = await getThread(locals.user.id, params.id); + return json({ messages }); + } catch (err) { + console.error('Thread fetch error:', err); + return json({ messages: [], error: 'Failed to fetch thread' }); } - - const thread = await gmail.users.threads.get({ - userId: 'me', - id: params.id, - format: 'full' - }); - - const messages = (thread.data.messages ?? []).map((msg) => parseMessage(msg)); - - return json({ messages }); }; diff --git a/src/routes/api/email/unread/+server.ts b/src/routes/api/email/unread/+server.ts index 5f46d79f..86c507ee 100644 --- a/src/routes/api/email/unread/+server.ts +++ b/src/routes/api/email/unread/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getGmailClient } from '$lib/server/gmail'; +import { getUnreadCount } from '$lib/server/email/service'; export const GET: RequestHandler = async ({ locals }) => { if (!locals.session || !locals.user) { @@ -8,15 +8,8 @@ export const GET: RequestHandler = async ({ locals }) => { } try { - const gmail = await getGmailClient(locals.user.id); - if (!gmail) return json({ count: 0 }); - - const label = await gmail.users.labels.get({ - userId: 'me', - id: 'INBOX' - }); - - return json({ count: label.data.messagesUnread ?? 0 }); + const count = await getUnreadCount(locals.user.id); + return json({ count }); } catch { return json({ count: 0 }); } diff --git a/src/routes/inbox/+page.server.ts b/src/routes/inbox/+page.server.ts index 795d4d38..0bf226af 100644 --- a/src/routes/inbox/+page.server.ts +++ b/src/routes/inbox/+page.server.ts @@ -13,11 +13,17 @@ export const load: PageServerLoad = async ({ locals }) => { return { connected: false, emailAddress: null }; } - const { data: connection } = await supabase + // Only email providers gate the inbox — calendar connections (google_calendar, + // microsoft_calendar, calendly) also live in email_connections, so a bare + // .maybeSingle() would error once a user connects both email and a calendar. + const { data: connections } = await supabase .from('email_connections') - .select('email_address') + .select('email_address, provider') .eq('profile_id', user.id) - .maybeSingle(); + .in('provider', ['gmail', 'outlook']); + + const emailRows = connections ?? []; + const connection = emailRows.find((c) => c.provider === 'gmail') ?? emailRows[0] ?? null; // Load account email map for auto-linking + manual links const orgId = locals.organization?.id; diff --git a/src/routes/inbox/+page.svelte b/src/routes/inbox/+page.svelte index 756d4cb7..8578034e 100644 --- a/src/routes/inbox/+page.svelte +++ b/src/routes/inbox/+page.svelte @@ -4,6 +4,7 @@ import { Button } from '$lib/components/ui/button/index.js'; import { Input } from '$lib/components/ui/input/index.js'; import ComposeModal from '$lib/components/email/ComposeModal.svelte'; + import { renderEmailBody } from '$lib/utils/sanitize-email.js'; type EmailItem = { id: string; @@ -22,6 +23,7 @@ fromEmail: string; date: string; body: string; + bodyIsHtml: boolean; }; let { data } = $props(); @@ -188,7 +190,13 @@ const res = await fetch(`/api/email/thread/${threadId}`); if (res.ok) { const json = await res.json(); - type ThreadMsg = { id: string; from?: string; date?: string; body?: string }; + type ThreadMsg = { + id: string; + from?: string; + date?: string; + body?: string; + bodyIsHtml?: boolean; + }; threadMessages = (json.messages ?? []).map((m: ThreadMsg) => { const parsed = parseFromHeader(m.from ?? ''); return { @@ -196,7 +204,8 @@ from: parsed.name, fromEmail: parsed.email, date: m.date ?? '', - body: m.body ?? '' + body: m.body ?? '', + bodyIsHtml: m.bodyIsHtml ?? false }; }); } @@ -223,7 +232,8 @@ to: selectedEmail.fromEmail, subject: `Re: ${selectedEmail.subject}`, body: replyBody, - threadId: selectedEmail.threadId + threadId: selectedEmail.threadId, + inReplyTo: selectedEmail.id }) }); if (res.ok) { @@ -281,7 +291,7 @@

Connect your email

- Connect your Gmail account to send and receive emails directly from Threadline. + Connect your Gmail or Outlook account to send and receive emails directly from Threadline.

@@ -573,8 +583,9 @@ {formatRelativeTime(message.date)} -
- {message.body} +
{/each} @@ -604,3 +615,31 @@ (composeOpen = !composeOpen)} /> {/if} + + diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index a6b698ae..8a02c970 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -7,6 +7,8 @@ import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; import Switch from '$lib/components/ui/switch.svelte'; + import ConnectionCard from '$lib/components/settings/ConnectionCard.svelte'; + import { resolve } from '$app/paths'; import { toast } from 'svelte-sonner'; import { preferences, @@ -17,6 +19,13 @@ let { data } = $props(); + const anyEmailConnected = $derived(data.emailConnected || data.outlookConnected); + const anyCalendarConnected = $derived( + data.calendarConnected || data.msCalendarConnected || data.calendlyConnected + ); + const connectLinkClass = + 'text-sm font-medium text-foreground transition-colors hover:text-muted-foreground'; + const np = $derived(data.notificationPreferences); let prefOrderUpdates = $state(true); let prefComments = $state(true); @@ -347,192 +356,193 @@ {/if} -
- - {#if data.emailConnected} -
-
- -
-

{data.emailAddress}

-

Gmail

+
+ {#if anyEmailConnected} +
+ {#if data.emailConnected} + + {/if} + {#if data.outlookConnected} + + {/if} + {#if !data.emailConnected || !data.outlookConnected} +
+ {#if !data.emailConnected} + + Connect Gmail instead + {/if} + {#if !data.outlookConnected} + + Connect Outlook instead + {/if}
-
+ {/if} +
+ {:else} +
-
- {:else} - - {/if} - - - {#if data.outlookConnected} -
-
- -
-

{data.outlookEmail}

-

Outlook

-
-
- {:else} - {/if}
@@ -610,251 +620,246 @@
{/if} -
- - {#if data.calendarConnected} -
-
- -
-

{data.calendarEmail}

-

Google Calendar

+
+ {#if anyCalendarConnected} +
+ {#if data.calendarConnected} + + {/if} + {#if data.msCalendarConnected} + + {/if} + {#if data.calendlyConnected} + + {/if} + {#if !data.calendarConnected || !data.msCalendarConnected || !data.calendlyConnected} +
+ {#if !data.calendarConnected} + + Google Calendar + {/if} + {#if !data.msCalendarConnected} + + Microsoft Calendar + {/if} + {#if !data.calendlyConnected} + + Calendly + {/if}
-
+ {/if} +
+ {:else} +
-
- {:else} - - {/if} - - - {#if data.msCalendarConnected} -
-
- -
-

{data.msCalendarEmail}

-

Microsoft Calendar

-
-
-
- {:else} - - {/if} - - - {#if data.calendlyConnected} -
-
- -
-

{data.calendlyEmail}

-

Calendly

-
-
- {:else} - {/if}