PageSourceSearch

https://smrtbld.ai/js/graph/outlook-mail.js

js smrtbld.ai collected 2026-10-05 15:11:05 UTC 9,173 bytes, 236 lines download raw bytes

1/**
2 * Outlook Mail operations via Microsoft Graph API.
3 *
4 * Uses the lightweight graphGet/graphPost wrapper (no MS Graph SDK).
5 * All responses use $select to minimize payload. HTML bodies are
6 * sanitized before returning to prevent XSS.
7 *
8 * Usage:
9 *   import { outlookMail } from '../graph/outlook-mail.js';
10 *   const { messages, nextLink } = await outlookMail.getInbox();
11 *   const detail = await outlookMail.getDetail(messageId);
12 */
13
14import { graphGet, graphPost } from './graph-client.js';
15import { sanitizeEmailHtml, cleanupEmailRenderingHtml } from '../utils/html-sanitizer.js';
16import { downloadBlob } from '../utils/download.js';
17
18const SELECT_LIST = 'id,sender,subject,bodyPreview,toRecipients,ccRecipients,sentDateTime,isRead,hasAttachments,importance';
19const SELECT_DETAIL = 'id,sender,subject,body,bodyPreview,toRecipients,ccRecipients,bccRecipients,replyTo,sentDateTime,hasAttachments,internetMessageId,importance,isRead';
20const SELECT_ATTACHMENT = 'id,name,contentType,size,lastModifiedDateTime,isInline';
21
22export const outlookMail = {
23
24    /**
25     * Get inbox messages with pagination.
26     * @param {number} pageSize — Messages per page (default 25)
27     * @param {string|null} nextLink — Pagination link from previous call
28     * @returns {{ messages: Array, nextLink: string|null }}
29     */
30    async getInbox(pageSize = 25, nextLink = null) {
31        let response;
32
33        if (nextLink) {
34            response = await graphGet(nextLink);
35        } else {
36            response = await graphGet(
37                `/me/mailFolders/inbox/messages?$select=${SELECT_LIST}&$orderby=sentDateTime desc&$top=${pageSize}`
38            );
39        }
40
41        // Filter out meeting messages (calendar invites etc.)
42        const messages = (response?.value ?? []).filter(msg => !msg.meetingMessageType);
43
44        return {
45            messages: messages.map(mapToListItem),
46            nextLink: response?.['@odata.nextLink'] ?? null,
47        };
48    },
49
50    /**
51     * Get a single message with full body and attachments.
52     * @param {string} messageId
53     * @returns {{ message: Object, attachments: Array }}
54     */
55    async getDetail(messageId) {
56        const message = await graphGet(`/me/messages/${messageId}?$select=${SELECT_DETAIL}`);
57
58        // Sanitize (security) then clean up rendering (quality) — see html-sanitizer.js
59        // for why these are two passes, not one.
60        const sanitizedBody = cleanupEmailRenderingHtml(
61            sanitizeEmailHtml(message?.body?.content ?? '')
62        );
63
64        // Fetch attachments only if they exist
65        let attachments = [];
66        if (message?.hasAttachments) {
67            const attachResponse = await graphGet(`/me/messages/${messageId}/attachments?$select=${SELECT_ATTACHMENT}`);
68            attachments = (attachResponse?.value ?? [])
69                .filter(a => !a.isInline)
70                .map(a => ({
71                    id: a.id,
72                    name: a.name,
73                    contentType: a.contentType,
74                    size: a.size,
75                    date: a.lastModifiedDateTime,
76                }));
77        }
78
79        return {
80            message: mapToDetail(message, sanitizedBody),
81            attachments,
82        };
83    },
84
85    /**
86     * Get attachment content (base64-encoded bytes).
87     * @param {string} messageId
88     * @param {string} attachmentId
89     * @returns {{ name, contentType, contentBytes, size }}
90     */
91    async getAttachment(messageId, attachmentId) {
92        return graphGet(`/me/messages/${messageId}/attachments/${attachmentId}`);
93    },
94
95    /**
96     * Reply to a message.
97     * @param {string} messageId
98     * @param {string} comment — HTML reply body
99     * @param {boolean} replyAll
100     */
101    async reply(messageId, comment, replyAll = false) {
102        const endpoint = replyAll
103            ? `/me/messages/${messageId}/replyAll`
104            : `/me/messages/${messageId}/reply`;
105        await graphPost(endpoint, { comment });
106    },
107
108    /**
109     * Send a new email.
110     * @param {{ to: string[], cc?: string[], subject: string, body: string }}
111     */
112    async send({ to, cc, subject, body }) {
113        const message = {
114            subject,
115            body: { contentType: 'html', content: body },
116            toRecipients: to.map(addr => ({ emailAddress: { address: addr } })),
117            ccRecipients: (cc ?? []).map(addr => ({ emailAddress: { address: addr } })),
118        };
119        await graphPost('/me/sendMail', { message, saveToSentItems: true });
120    },
121
122    /**
123     * Search messages using the /search/query POST endpoint, with offset paging.
124     * The Search API pages by a `from` offset (there is no @odata.nextLink here); the
125     * response's `moreResultsAvailable` flag reports whether further hits exist.
126     * @param {string} query — Search string
127     * @param {number} maxResults — Page size (Graph caps message search at 25)
128     * @param {number} from — Zero-based offset of the first hit to return
129     * @returns {{ messages: Array, from: number, hasMore: boolean }}
130     *          `from` is the offset to pass for the NEXT page.
131     */
132    async search(query, maxResults = 25, from = 0) {
133        const searchRequest = {
134            requests: [{
135                entityTypes: ['message'],
136                query: { queryString: query },
137                from,
138                size: maxResults,
139                enableTopResults: false,
140            }],
141        };
142
143        const response = await graphPost('/search/query', searchRequest);
144        const container = response?.value?.[0]?.hitsContainers?.[0];
145        const hits = container?.hits ?? [];
146
147        const messages = hits
148            .filter(hit => !hit.hitId?.includes('/'))
149            .filter(hit => !hit.resource?.meetingMessageType)
150            .map(hit => mapToListItem({ id: hit.hitId, ...hit.resource }));
151
152        return {
153            messages,
154            // Advance by RAW hits consumed (not filtered messages) so the next page's
155            // offset stays aligned with Graph — dropped meetings mustn't shift it.
156            from: from + hits.length,
157            hasMore: container?.moreResultsAvailable ?? false,
158        };
159    },
160
161    /**
162     * Mark a message as read.
163     * @param {string} messageId
164     */
165    async markAsRead(messageId) {
166        const { graphPatch } = await import('./graph-client.js');
167        await graphPatch(`/me/messages/${messageId}`, { isRead: true });
168    },
169};
170
171// ─── Mappers ────────────────────────────────────────────
172
173function mapToListItem(msg) {
174    return {
175        id: msg.id,
176        source: 'outlook',
177        from: msg.sender?.emailAddress?.name || msg.sender?.emailAddress?.address || 'Unknown',
178        fromEmail: msg.sender?.emailAddress?.address ?? '',
179        subject: msg.subject ?? '(No Subject)',
180        preview: msg.bodyPreview ?? '',
181        timestamp: msg.sentDateTime,
182        isRead: msg.isRead ?? false,
183        hasAttachments: msg.hasAttachments ?? false,
184        importance: msg.importance ?? 'normal',
185    };
186}
187
188function mapToDetail(msg, sanitizedBody) {
189    return {
190        id: msg.id,
191        source: 'outlook',
192        from: msg.sender?.emailAddress?.name || msg.sender?.emailAddress?.address || 'Unknown',
193        fromEmail: msg.sender?.emailAddress?.address ?? '',
194        subject: msg.subject ?? '(No Subject)',
195        body: sanitizedBody,
196        preview: msg.bodyPreview ?? '',
197        timestamp: msg.sentDateTime,
198        isRead: msg.isRead ?? false,
199        hasAttachments: msg.hasAttachments ?? false,
200        importance: msg.importance ?? 'normal',
201        toRecipients: msg.toRecipients?.map(r =>
201 r.emailAddress?.address).filter(Boolean) ?? [],
202        ccRecipients: msg.ccRecipients?.map(r => r.emailAddress?.address).filter(Boolean) ?? [],
203        bccRecipients: msg.bccRecipients?.map(r => r.emailAddress?.address).filter(Boolean) ?? [],
204        internetMessageId: msg.internetMessageId ?? '',
205    };
206}
207
208// ─── Attachment Download ────────────────────────────────
209
210/**
211 * Download an attachment as a file.
212 * @param {string} messageId
213 * @param {string} attachmentId
214 * @param {string} fileName
215 */
216export async function downloadAttachment(messageId, attachmentId, fileName) {
217    const attachment = await outlookMail.getAttachment(messageId, attachmentId);
218    if (!attachment?.contentBytes) return;
219
220    const blob = base64ToBlob(attachment.contentBytes, attachment.contentType);
221    downloadBlob(blob, fileName || attachment.name);
222}
223
224function base64ToBlob(base64, contentType) {
225    const byteChars = atob(base64);
226    const byteArrays = [];
227    for (let offset = 0; offset < byteChars.length; offset += 512) {
228        const slice = byteChars.slice(offset, offset + 512);
229        const byteNumbers = new Array(slice.length);
230        for (let i = 0; i < slice.length; i++) {
231            byteNumbers[i] = slice.charCodeAt(i);
232        }
233        byteArrays.push(new Uint8Array(byteNumbers));
234    }
235    return new Blob(byteArrays, { type: contentType });
236}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.