// AI 이메일 답변 초안 자동화 실습 V4 (키워드 분류 + 노션 확인 흐름) // 사용법: 아래 TEST_EMAIL만 본인 Gmail 주소로 바꾸고 저장합니다. const CONFIG = { TEST_EMAIL: '이메일주소', MARKER: 'AI실습V3', SEARCH: '"AI실습V3" newer_than:30d -in:trash -in:spam' }; // 유형별 키워드: 제목과 본문에 들어 있는 키워드 수로 유형을 고릅니다. const KEYWORDS = { '견적': ['견적', '비용', '단가', '금액', '가격'], '일정': ['일정', '회의', '미팅', '시간', '방문', '조율'], '자료': ['자료', '첨부', '검토', '보고서', '결과'], '운영': ['교육', '안내', '좌석', '장소', '준비'] }; // 파트 2에서 AI로 만든 문구로 바꿔 넣습니다. (작은따옴표 ' 는 쓰지 마세요) const TEMPLATES = { '견적': '안녕하세요. 견적 문의 감사드립니다. 정확한 견적을 위해 분석 대상 물질명, 검체 종류, 분석 방법, 희망 일정을 알려 주시면 검토 후 회신드리겠습니다. 감사합니다.', '일정': '안녕하세요. 일정 조율 요청 감사드립니다. 가능한 시간을 확인한 뒤 회신드리겠습니다. 감사합니다.', '자료': '안녕하세요. 자료 검토 요청을 확인했습니다. 내용을 검토한 뒤 결과를 회신드리겠습니다. 감사합니다.', '운영': '안녕하세요. 교육 관련 문의 감사드립니다. 준비 사항을 확인한 뒤 안내드리겠습니다. 감사합니다.' }; const COMMON_REPLY = '안녕하세요. 보내 주신 내용을 확인했습니다. 담당자가 내용을 확인한 뒤 회신드리겠습니다. 감사합니다.'; // ---------- 1. 계정 확인 ---------- function testAccount() { const actual = Session.getEffectiveUser().getEmail().toLowerCase(); const expected = CONFIG.TEST_EMAIL.trim().toLowerCase(); if (!/^[^\s@]+@gmail\.com$/.test(expected)) { throw new Error('TEST_EMAIL에 본인 gmail.com 주소를 입력하세요. (이 실습은 gmail.com 계정만 지원)'); } if (!actual || actual !== expected) { throw new Error('실행 계정을 확인하세요. 현재 계정: ' + actual); } console.log('계정 확인: ' + actual); } // ---------- 2. 실습 메일 6건을 본인에게 보내기 (한 번만) ---------- function seedMails() { testAccount(); const props = PropertiesService.getUserProperties(); if (props.getProperty('SEEDED_V3')) { console.log('이미 실습 메일을 보냈습니다. Gmail에서 AI실습V3를 검색하세요.'); return; } const items = [ ['PK 분석 견적 문의드립니다', '임상 1상 시료 약 300개의 분석 비용과 가능한 일정을 알려 주세요.'], ['다음 주 회의 일정 조율 부탁드립니다', '화요일이나 수요일 오후 중 미팅 가능한 시간을 알려 주세요.'], ['분석 결과 보고서 검토 요청', '첨부한 보고서 초안을 확인해 주세요.'], ['교육 장소와 좌석 안내 요청', '다음 달 참석 인원은 20명입니다. 필요한 준비 사항을 알려 주세요.'], ['지난번 통화 관련 연락드립니다', '편하실 때 회신 부탁드립니다.'], ['자료 전달 일정 문의', '요청드린 자료를 언제 받을 수 있는지 일정을 알려 주세요.'] ]; for (const item of items) { GmailApp.sendEmail( CONFIG.TEST_EMAIL.trim(), item[0], item[1] + '\n\n-- 교육용 가상 메일 (' + CONFIG.MARKER + ')' ); } props.setProperty('SEEDED_V3', 'done'); console.log('실습 메일 ' + items.length + '건을 보냈습니다. 1분쯤 뒤 inspectEmails를 실행하세요.'); } // ---------- 3. 대상 메일 찾기 ---------- function targetEmails_() { testAccount(); const found = []; const seen = new Set(); const threads = GmailApp.search(CONFIG.SEARCH, 0, 100); const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; const pattern = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i; for (const thread of threads) { for (const message of thread.getMessages()) { if (message.isDraft() || message.isInTrash()) continue; if (message.getDate().getTime() < cutoff) continue; if (/^(re|fw|fwd)\s*:/i.test(message.getSubject())) continue; const from = (message.getFrom().match(pattern) || [''])[0]; if (from.toLowerCase() !== CONFIG.TEST_EMAIL.trim().toLowerCase()) continue; if (message.getPlainBody().indexOf(CONFIG.MARKER) === -1) continue; if (seen.has(message.getId())) continue; seen.add(message.getId()); found.push(message); } } return found; } function inspectEmails() { const messages = targetEmails_(); for (const message of messages) { console.log('제목: ' + message.getSubject()); console.log('본문: ' + bodyText_(message).slice(0, 120)); } console.log('대상 메일: ' + messages.length + '건'); } // ---------- 4. 키워드로 분류하고 답변 문구 고르기 ---------- function bodyText_(message) { // 실습 표식 줄은 분류에서 뺍니다. return message.getPlainBody().split('\n').filter(line => line.indexOf(CONFIG.MARKER) === -1).join('\n').trim(); } function classify_(subject, body) { const text = subject + ' ' + body; const scores = {}; for (const type of Object.keys(KEYWORDS)) { scores[type] = KEYWORDS[type].filter(word => text.indexOf(word) !== -1).length; } const max = Math.max.apply(null, Object.values(scores)); const best = Object.keys(scores).filter(type => scores[type] === max); const type = (max === 0 || best.length > 1) ? '기타' : best[0]; const detail = Object.keys(scores).filter(t => scores[t] > 0).map(t => t + ' ' + scores[t]).join(', ') || '키워드 없음'; return { type: type, detail: detail }; } function makeReply_(type) { return TEMPLATES[type] || COMMON_REPLY; } function previewReplies() { const messages = targetEmails_(); for (const message of messages) { const result = classify_(message.getSubject(), bodyText_(message)); console.log('제목: ' + message.getSubject()); console.log('분류: ' + result.type + ' (' + result.detail + ')'); console.log('답변 예정: ' + makeReply_(result.type)); } console.log('미리보기: ' + messages.length + '건'); } // ---------- 5. Gmail 답변 초안 만들기 (발송하지 않음) ---------- function createDrafts() { const lock = LockService.getUserLock(); if (!lock.tryLock(1000)) throw new Error('다른 실행이 진행 중입니다.'); try { const props = PropertiesService.getUserProperties(); const messages = targetEmails_(); let created = 0; let skipped = 0; for (const message of messages) { const key = 'REPLY_DRAFT_' + message.getId(); if (props.getProperty(key)) { console.log('이미 처리했거나 확인 대기: ' + message.getSubject()); skipped++; continue; } const result = classify_(message.getSubject(), bodyText_(message)); // 생성 결과가 불확실한 경우 자동으로 다시 만들지 않습니다. props.setProperty(key, '확인대기'); const draft = message.createDraftReply(makeReply_(result.type)); props.setProperty(key, draft.getId()); console.log('초안 생성 [' + result.type + ']: ' + message.getSubject()); created++; } console.log('새 초안 ' + created + '건 / 제외 ' + skipped + '건'); } finally { lock.releaseLock(); } } // ---------- 6. 처음부터 다시 연습하고 싶을 때 ---------- // 실행 전에 Gmail 임시보관함의 연습용 초안과 노션 표의 줄을 직접 지우세요. function resetPractice() { const props = PropertiesService.getUserProperties(); const all = props.getProperties(); let removed = 0; for (const key of Object.keys(all)) { if (key.startsWith('REPLY_DRAFT_') || key.startsWith('NOTION_LOG_') || key === 'SEEDED_V3') { props.deleteProperty(key); removed++; } } console.log('처리 기록 ' + removed + '건을 지웠습니다. seedMails로 실습 메일을 다시 보낼 수 있습니다.'); } // ===================================================== // 7. 노션 먼저 흐름 (강사 시연용) // 메일 → 노션에 보관·분류 → 사람이 노션에서 확인 → 확인된 것만 초안 // 준비: 프로젝트 설정 → 스크립트 속성에 NOTION_TOKEN 추가 // 노션 표 열: 제목(제목), 받은날짜(날짜), 답변문구(텍스트), 유형(선택), 상태(선택), 메일ID(텍스트) // ===================================================== const NOTION = { DATABASE_ID: '데이타베이스', VERSION: '2022-06-28', COLUMNS: { title: '제목', date: '받은날짜', reply: '답변문구', type: '유형', status: '상태', id: '메일ID' }, STATUS: { saved: '분류됨', confirmed: '확인완료', drafted: '초안생성' } }; function notionFetch_(path, method, payload) { const token = PropertiesService.getScriptProperties().getProperty('NOTION_TOKEN'); if (!token) throw new Error('스크립트 속성에 NOTION_TOKEN을 추가하세요.'); const options = { method: method, headers: { Authorization: 'Bearer ' + token, 'Notion-Version': NOTION.VERSION }, contentType: 'application/json', muteHttpExceptions: true }; if (payload) options.payload = JSON.stringify(payload); const res = UrlFetchApp.fetch('https://api.notion.com/v1/' + path, options); const code = res.getResponseCode(); const body = JSON.parse(res.getContentText()); if (code >= 300) throw new Error('노션 오류 ' + code + ': ' + body.message); return body; } // 연결 확인: 열 이름과 유형이 맞는지 봅니다. function testNotion() { const db = notionFetch_('databases/' + NOTION.DATABASE_ID, 'get'); console.log('데이터베이스 연결 성공'); for (const name of Object.keys(db.properties)) { console.log('열: ' + name + ' (' + db.properties[name].type + ')'); } const c = NOTION.COLUMNS; const need = [[c.title, 'title'], [c.date, 'date'], [c.reply, 'rich_text'], [c.type, 'select'], [c.status, 'select'], [c.id, 'rich_text']]; const missing = need.filter(n => !db.properties[n[0]] || db.properties[n[0]].type !== n[1]).map(n => n[0] + '(' + n[1] + ')'); console.log(missing.length ? '맞지 않는 열: ' + missing.join(', ') : '열 6개가 모두 맞습니다.'); } function text_(content) { return [{ text: { content: String(content).slice(0, 1900) } }]; } function plain_(prop) { if (!prop) return ''; if (prop.type === 'select') return prop.select ? prop.select.name : ''; const parts = prop[prop.type] || []; return parts.map(p => p.plain_text || (p.text && p.text.content) || '').join(''); } // 1단계: 메일을 노션에 보관하고 유형을 자동으로 넣습니다. 상태 = 분류됨 function saveToNotion() { const props = PropertiesService.getUserProperties(); const messages = targetEmails_(); const c = NOTION.COLUMNS; let created = 0; let skipped = 0; for (const message of messages) { const key = 'NOTION_LOG_' + message.getId(); if (props.getProperty(key)) { skipped++; continue; } const subject = message.getSubject(); const result = classify_(subject, bodyText_(message)); const properties = {}; properties[c.title] = { title: text_(subject) }; properties[c.type] = { select: { name: result.type } }; properties[c.date] = { date: { start: Utilities.formatDate(message.getDate(), Session.getScriptTimeZone(), 'yyyy-MM-dd') } }; properties[c.status] = { select: { name: NOTION.STATUS.saved } }; properties[c.id] = { rich_text: text_(message.getId()) }; notionFetch_('pages', 'post', { parent: { database_id: NOTION.DATABASE_ID }, properties: properties }); props.setProperty(key, 'done'); console.log('노션 보관 [' + result.type + ' / ' + result.detail + ']: ' + subject); created++; } console.log('노션 새 보관 ' + created + '건 / 제외 ' + skipped + '건'); console.log('이제 노션에서 유형을 확인하고, 맞으면 상태를 ' + NOTION.STATUS.confirmed + '(으)로 바꾸세요.'); } // 2단계: 노션에서 상태가 확인완료인 줄만 읽어, 그 유형의 문구로 Gmail 초안을 만듭니다. function draftsFromNotion() { const lock = LockService.getUserLock(); if (!lock.tryLock(1000)) throw new Error('다른 실행이 진행 중입니다.'); try { testAccount(); const c = NOTION.COLUMNS; const props = PropertiesService.getUserProperties(); const query = notionFetch_('databases/' + NOTION.DATABASE_ID + '/query', 'post', { filter: { property: c.status, select: { equals: NOTION.STATUS.confirmed } }, page_size: 100 }); let created = 0; let skipped = 0; for (const page of query.results) { const p = page.properties; const subject = plain_(p[c.title]); const type = plain_(p[c.type]) || '기타'; const id = plain_(p[c.id]).trim(); const key = 'REPLY_DRAFT_' + id; if (!id || props.getProperty(key)) { console.log('건너뜀(메일ID 없음 또는 이미 초안 있음): ' + subject); skipped++; continue; } const message = GmailApp.getMessageById(id); if (!message) { console.log('메일을 찾지 못함: ' + subject); skipped++; continue; } const reply = makeReply_(type); props.setProperty(key, '확인대기'); const draft = message.createDraftReply(reply); props.setProperty(key, draft.getId()); const update = {}; update[c.status] = { select: { name: NOTION.STATUS.drafted } }; update[c.reply] = { rich_text: text_(reply) }; notionFetch_('pages/' + page.id, 'patch', { properties: update }); console.log('초안 생성 [' + type + ']: ' + subject); created++; } console.log('확인완료 ' + query.results.length + '건 중 새 초안 ' + created + '건 / 제외 ' + skipped + '건'); } finally { lock.releaseLock(); } }