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.