import { eventBus } from '../lib/eventBus.js'; import { createError } from '../lib/utils.js'; import { contentLoader } from './ContentLoader.js'; import { saveState } from './SaveState.js'; const CHARACTER_EMAILS = { marcus: 'Marcus Webb ', sarah: 'Sarah Chen ', priya: 'Priya Nair ', alex: 'Alex Mercer ', dave: 'Dave Okonkwo ', monitoring: 'Monitoring ' }; class EmailService { constructor() { this._mail = []; } initialize(state) { this._mail = Array.isArray(state.mail) ? state.mail.map((mail) => this._normalizeMail(mail)) : []; if (this._mail.length === 0) { this.send({ id: 'mail-T001-initial', from: 'Marcus Webb ', subject: 'Your workstation access', body: 'Hey, welcome to the team. HR said you started today so I got you set up with an account on ares. The provisioning script runs automatically but it does not handle SSH keys \u2014 you will need to add yours manually. Your public key should be in the onboarding doc. Let me know if you get stuck.\n\n\u2014 Marcus', attachments: ['docs/onboarding.json'], replyOptions: [ { label: 'Got it, I\'ll get that sorted.', dialogue_node: 'marcus-Q001-reply-a' }, { label: 'Where do I find the onboarding doc?', dialogue_node: 'marcus-Q001-reply-b' } ] }); } } getAll() { return this._mail.map((mail) => ({ id: mail.id, from: mail.from, subject: mail.subject, timestamp: mail.timestamp, read: mail.read, replied: mail.replied })); } getById(id) { return this._mail.find((mail) => mail.id === id) ?? null; } markRead(id) { const mail = this.getById(id); if (!mail) { throw createError(`Unknown mail: ${id}`, 404); } mail.read = true; this._persist(); } send({ id, from, subject, body, attachments = [], replyOptions = [] }) { const record = this._normalizeMail({ id, from, subject, body, attachments, reply_options: replyOptions, read: false, replied: false, timestamp: new Date().toISOString() }); this._mail.push(record); this._persist(); eventBus.emit('mail:new', { id, from, subject }); return record; } reply(mailId, choiceIndex) { const mail = this.getById(mailId); if (!mail) { throw createError(`Unknown mail: ${mailId}`, 404); } const choices = mail.reply_options ?? []; if (!Number.isInteger(choiceIndex) || choiceIndex < 0 || choiceIndex >= choices.length) { throw createError('Invalid reply choice', 400); } mail.replied = true; const selectedChoice = choices[choiceIndex]; const responseBody = this._resolveDialogueBody(selectedChoice.dialogue_node, mail, selectedChoice); this._persist(); this.send({ id: `${mailId}-reply-${choiceIndex}`, from: mail.from, subject: `Re: ${mail.subject}`, body: responseBody, attachments: [], replyOptions: [] }); return { ok: true }; } sendDialogueFollowUp(dialogueNodeId, options = {}) { const resolved = this._resolveDialogueMessage(dialogueNodeId); if (!resolved?.body) { return null; } const { questId = resolved.questId, ticketId = resolved.questId ? contentLoader.get('quests', resolved.questId)?.ticket_id : null, subjectPrefix = 'Follow-up', idPrefix = 'mail-followup' } = options; const from = CHARACTER_EMAILS[resolved.character] ?? CHARACTER_EMAILS.monitoring; const subject = ticketId ? `${subjectPrefix}: ${ticketId}` : `${subjectPrefix}: ${dialogueNodeId}`; return this.send({ id: `${idPrefix}-${dialogueNodeId}-${Date.now()}`, from, subject, body: resolved.body, attachments: [], replyOptions: [] }); } _persist() { saveState.set({ mail: this._mail }); } _normalizeMail(mail) { return { ...mail, attachments: [...(mail.attachments ?? [])], reply_options: [...(mail.reply_options ?? mail.replyOptions ?? [])], read: Boolean(mail.read), replied: Boolean(mail.replied), timestamp: mail.timestamp ?? new Date().toISOString() }; } _resolveDialogueBody(dialogueNode, mail, choice) { const resolvedMessage = this._resolveDialogueMessage(dialogueNode, mail); if (resolvedMessage?.body) { return resolvedMessage.body; } return choice?.label ? `Noted.\n\n${choice.label}` : 'Noted.'; } _resolveDialogueMessage(dialogueNode, mail = null) { const directMatch = contentLoader.get('dialogue', dialogueNode); if (directMatch?.body) { return { body: directMatch.body, character: directMatch.character ?? null, questId: directMatch.quest_id ?? null }; } const parsed = this._parseDialogueNodeReference(dialogueNode); if (parsed) { const dialogue = contentLoader.get('dialogue', parsed.baseId); const message = dialogue?.messages?.find((entry) => entry.stage === parsed.stage); if (message?.body) { return { body: message.body, character: dialogue.character ?? null, questId: dialogue.quest_id ?? null }; } } const fallbackMatch = this._findDialogueFallback(dialogueNode, mail); if (fallbackMatch?.dialogue?.body) { return { body: fallbackMatch.dialogue.body, character: fallbackMatch.dialogue.character ?? null, questId: fallbackMatch.dialogue.quest_id ?? null }; } if (Array.isArray(fallbackMatch?.dialogue?.messages) && fallbackMatch.dialogue.messages.length > 0) { return { body: fallbackMatch.dialogue.messages[0].body, character: fallbackMatch.dialogue.character ?? null, questId: fallbackMatch.dialogue.quest_id ?? null }; } return null; } _findDialogueFallback(dialogueNode, mail) { const parsed = this._parseDialogueNodeReference(dialogueNode); const baseId = parsed?.baseId ?? dialogueNode.replace(/-reply-[a-z0-9]+$/i, ''); const candidates = [baseId]; const ticketMatch = baseId.match(/^([^-]+)-T(\d{3})$/i); if (ticketMatch) { const [, character, ticketNumber] = ticketMatch; const ticket = contentLoader.get('tickets', `T${ticketNumber}`); if (ticket?.linked_quest) { candidates.push(`${character}-${ticket.linked_quest}`); } } candidates.push(mail?.from?.toLowerCase().includes('marcus') ? 'marcus-Q001' : null); for (const candidate of candidates) { if (!candidate) { continue; } const dialogue = contentLoader.get('dialogue', candidate); if (dialogue) { return { dialogue, baseId: candidate }; } } return null; } _parseDialogueNodeReference(dialogueNode) { if (typeof dialogueNode !== 'string') { return null; } const match = dialogueNode.match(/^([^-]+-Q\d{3})-(.+)$/i); if (!match) { return null; } return { baseId: match[1], stage: match[2] }; } } export const emailService = new EmailService();