Google Apps Script 설정 도구

테스트 단계용 Code.gs와 설정 안내 문서를 내려받습니다. 이 컴포넌트는 배포/실행을 자동으로 하지 않습니다.

Code.gs 미리보기 (접기/펼치기)
var RECIPIENT = "multiscale1104@gmail.com";
var SHEET_NAME = "Requests";
var TZ = "Asia/Seoul";
var DAILY_LIMIT = 30;

function setup() {
  var props = PropertiesService.getScriptProperties();
  var sheetId = props.getProperty("SHEET_ID");
  var ss = sheetId ? SpreadsheetApp.openById(sheetId) : SpreadsheetApp.create("MSI_Analysis_Requests_Private");
  var sheet = ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME);
  if (sheet.getLastRow() === 0) {
    sheet.appendRow(["requestId","clientRequestId","createdAt","name","org","email","phone","summary","json","payloadHash","mailStatus","error"]);
  }
  props.setProperty("SHEET_ID", ss.getId());
  Logger.log("SHEET_URL: " + ss.getUrl());
  MailApp.getRemainingDailyQuota();
  return { ok: true, sheetId: ss.getId(), sheetUrl: ss.getUrl() };
}

function doGet() {
  return ContentService.createTextOutput(JSON.stringify({ ok: true, service: "MSI_TEST", time: nowKst() }))
    .setMimeType(ContentService.MimeType.JSON);
}

function doPost(e) {
  var p, raw;
  try {
    if (!e || !e.parameter || !e.parameter.payload) return plainError("invalid payload");
    raw = String(e.parameter.payload || "");
    if (utf8Bytes(raw) > 60000) return plainError("payload too large");
    p = JSON.parse(raw);
  } catch (_err) {
    return plainError("invalid payload");
  }
  if (!isValidOrigin_(String(p.returnOrigin || ""))) return plainError("invalid origin");
  if (!/^[A-Za-z0-9_-]{20,100}$/.test(String(p.clientRequestId || ""))) return plainError("invalid id");
  if (!/^[A-Za-z0-9_-]{20,100}$/.test(String(p.nonce || ""))) return plainError("invalid id");
  try {
    var result = processSubmission_(p, raw);
    return htmlAck_(result.returnOrigin, result.ack);
  } catch (_err2) {
    return htmlAck_(String(p.returnOrigin), ack_(p, "", false, false, "요청 처리에 실패했습니다."));
  }
}

function processSubmission_(p, raw) {
  validateTop_(p, raw);
  var lock = LockService.getScriptLock();
  lock.waitLock(20000);
  var requestId = "";
  var rowIndex = 0;
  var saved = false;
  var mailSent = false;
  try {
    var sheet = openSheet_().getSheetByName(SHEET_NAME);
    if (!sheet) throw new Error("sheet missing");
    var payloadHash = sha256_(JSON.stringify({ form: p.form, summaryText: p.summaryText, attachmentNames: p.attachmentNames || [] }));
    var existing = findByClientRequestId_(sheet, p.clientRequestId);
    if (existing) {
      if (existing.payloadHash && existing.payloadHash !== payloadHash) throw new Error("id mismatch");
      return { returnOrigin: p.returnOrigin, ack: ack_(p, existing.requestId, true, existing.mailStatus === "SENT", existing.mailStatus === "SENT" ? "이미 접수된 요청입니다." : "이미 저장된 요청입니다.") };
    }
    enforceDailyLimit_();
    enforceEmailCooldown_(p.form.email);
    requestId = makeRequestId_();
    var row = [
      requestId, p.clientRequestId, nowKst(),
      safeCell_(p.form.applicantName), safeCell_(p.form.organization), safeCell_(p.form.email), safeCell_(p.form.phone),
      safeCell_(String(p.summaryText).slice(0, 40000)),
      safeCell_(JSON.stringify(p.form).slice(0, 40000)),
      payloadHash, "PENDING", ""
    ];
    sheet.appendRow(row);
    rowIndex = sheet.getLastRow();
    saved = true;
    try {
      MailApp.sendEmail({
        to: RECIPIENT,
        replyTo: p.form.email,
        subject: "[분석 의뢰] " + requestId,
        body: String(p.summaryText).slice(0, 40000) + "\n\n접수번호: " + requestId + "\n첨부파일은 업로드되지 않으며 파일명만 저장됩니다."
      });
      mailSent = true;
      sheet.getRange(rowIndex, 11).setValue("SENT");
      sheet.getRange(rowIndex, 12).setValue("");
    } catch (_mailErr) {
      sheet.getRange(rowIndex, 11).setValue("ERROR");
      sheet.getRange(rowIndex, 12).setValue("mail failed");
    }
    return { returnOrigin: p.returnOrigin, ack: ack_(p, requestId, true, mailSent, mailSent ? "접수 저장 및 메일 발송 요청이 완료되었습니다." : "접수 저장 완료 / 메일 발송 실패") };
  } catch (_err) {
    if (saved && rowIndex > 0) {
      try {
        var sh = openSheet_().getSheetByName(SHEET_NAME);
        sh.getRange(rowIndex, 11).setValue(mailSent ? "SENT" : "ERROR");
        if (!mailSent) sh.getRange(rowIndex, 12).setValue("unexpected error");
      } catch (_ignore) {}
      return { returnOrigin: p.returnOrigin, ack: ack_(p, requestId, true, false, "접수 저장 완료 / 메일 상태 확인 필요") };
    }
    throw _err;
  } finally {
    lock.releaseLock();
  }
}

function validateTop_(p, raw) {
  if (!p || p.schema !== "v1") throw new Error("schema");
  if (!/^[A-Za-z0-9_-]{20,100}$/.test(String(p.clientRequestId || ""))) throw new Error("clientRequestId");
  if (!/^[A-Za-z0-9_-]{20,100}$/.test(String(p.nonce || ""))) throw new Error("nonce");
  if (!isValidOrigin_(String(p.returnOrigin || ""))) throw new Error("origin");
  if (String(p.honeypot || "").trim()) throw new Error("bot");
  if (typeof p.summaryText !== "string" || p.summaryText.length > 40000) throw new Error("summary");
  if (!p.form || typeof p.form !== "object") throw new Error("form");
  if (JSON.stringify(p.form).length > 40000) throw new Error("form size");
  if (utf8Bytes(raw) > 60000) throw new Error("too large");
  if (p.form.consent !== true) throw new Error("consent");
  var name = String(p.form.applicantName || "").trim();
  var org = String(p.form.organization || "").trim();
  var phone = String(p.form.phone || "").trim();
  var email = String(p.form.email || "").trim();
  if (!name || !org || !phone || !email) throw new Error("required");
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error("email");
  if (!String(p.form.scientificQuestion || "").trim()) throw new Error("question");
  if (!Array.isArray(p.form.analysisTypes) || p.form.analysisTypes.length < 1) throw new Error("analysis");
  if (!String(p.form.selectedEquipmentCategory || "").trim() || !String(p.form.selectedEquipmentModel || "").trim()) throw new Error("equipment");
  if (!Array.isArray(p.form.samples) || p.form.samples.length < 1 || p.form.samples.length > 50) throw new Error("samples");
  for (var i = 0; i < p.form.samples.length; i++) {
    var s = p.form.samples[i] || {};
    if (!String(s.name || "").trim()) throw new Error("sample name");
    if (!String(s.material || "").trim()) throw new Error("sample material");
    if (!isFinite(Number(s.quantity)) || Number(s.quantity) % 1 !== 0 || Number(s.quantity) < 1) throw new Error("sample qty");
    if (String(s.safetyStatus || "") === "hazard" && !String(s.safetyNotes || "").trim()) throw new Error("sample hazard notes");
  }
}

function htmlAck_(returnOrigin, ack) {
  var json = JSON.stringify(ack).replace(/</g, "\\u003c").replace(/<\/script/gi, "<\\/script");
  var html = '<!doctype html><html><body><script>(function(){var m=' + json + ';var t="' + returnOrigin + '";var w=window;for(var i=0;i<12;i++){try{w.postMessage(m,t);if(w===w.parent)break;w=w.parent;}catch(e){break;}}})();</script></body></html>';
  return HtmlService.createHtmlOutput(html).setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

function ack_(p, requestId, saved, mailSent, msg) {
  return { type: "MSI_APPLICATION_RESULT", nonce: p.nonce, clientRequestId: p.clientRequestId, ok: saved, saved: saved, mailSent: mailSent, requestId: requestId, message: msg };
}

function plainError(msg) { return ContentService.createTextOutput(msg).setMimeType(ContentService.MimeType.TEXT); }
function openSheet_() { var id = PropertiesService.getScriptProperties().getProperty("SHEET_ID"); if (!id) throw new Error("setup required"); return SpreadsheetApp.openById(id); }
function nowKst() { return Utilities.formatDate(new Date(), TZ, "yyyy-MM-dd HH:mm:ss"); }
function makeRequestId_() { return "MSI-" + Utilities.formatDate(new Date(), TZ, "yyyyMMdd") + "-" + Utilities.getUuid().replace(/-/g, "").slice(0, 8).toUpperCase(); }
function safeCell_(v) { var s = String(v || ""); return /^[=+\-@]/.test(s) ? "'" + s : s; }
function utf8Bytes(s) { return Utilities.newBlob(String(s || ""), "text/plain").getBytes().length; }
function sha256_(s) { var bytes = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, s, Utilities.Charset.UTF_8); return bytes.map(function(b){var x=(b<0?b+256:b).toString(16); return x.length===1?"0"+x:x;}).join(""); }
function normEmail_(e) { return String(e || "").trim().toLowerCase(); }
function enforceEmailCooldown_(email) { var key = "cooldown_" + sha256_(normEmail_(email)); var cache = CacheService.getScriptCache(); if (cache.get(key)) throw new Error("too fast"); cache.put(key, "1", 60); }
function enforceDailyLimit_() { var p = PropertiesService.getScriptProperties(); var day = Utilities.formatDate(new Date(), TZ, "yyyyMMdd"); var key = "COUNT_" + day; var n = Number(p.getProperty(key) || "0"); if (n >= DAILY_LIMIT) throw new Error("daily limit"); p.setProperty(key, String(n + 1)); }
function findByClientRequestId_(sheet, id) { var last = sheet.getLastRow(); if (last < 2) return null; var values = sheet.getRange(2, 1, last - 1, 12).getValues(); for (var i = 0; i < values.length; i++) { if (String(values[i][1]) === id) return { requestId: String(values[i][0]), payloadHash: String(values[i][9] || ""), mailStatus: String(values[i][10] || "") }; } return null; }
function isValidOrigin_(origin) { return /^https:\/\/[A-Za-z0-9.-]+(?::\d+)?$/.test(origin) || /^http:\/\/localhost(?::\d+)?$/.test(origin); }
README 미리보기 (접기/펼치기)
README-설정안내.txt
[Google Apps Script 테스트 접수 설정 안내]

1) 계정 로그인
- 반드시 multiscale1104@gmail.com 계정으로 로그인합니다.

2) 새 스크립트 프로젝트 생성
- https://script.google.com 접속 → 새 프로젝트
- 이 컴포넌트에서 다운로드한 Code.gs 내용을 붙여넣고 저장

3) setup() 1회 실행
- 상단 함수 선택에서 setup 실행
- 권한 승인(Spreadsheet/Mail 필요)
- private 스프레드시트가 생성되고 Script Properties에 SHEET_ID 저장

4) 웹 앱 배포 (TEST 전용)
- 배포 > 새 배포 > 웹 앱
- Execute as: Me(소유자)
- Access: Anyone (테스트용 공개 쓰기)
- 배포 후 /exec URL 복사

5) Framer 연결
- AnalysisApplication의 "접수서버URL"에 /exec 주소 입력
- 작성내용확인 시 접수 저장 + 메일 발송 요청 ACK를 수신

[중요 제한/주의]
- 이 코드는 비밀키를 포함하지 않습니다.
- 공개 웹앱은 스팸 위험이 있습니다(테스트 데이터만 사용).
- 쿼터/레이트리밋은 보호를 일부만 제공합니다(완전한 보안 아님).
- 메일 SENT는 수신함 도착 보장이 아닙니다(수락 의미).
- 첨부파일 자체는 업로드되지 않고 파일명만 저장됩니다.
- 실제 고객 운영 전 인증/악용방지/보존정책/삭제정책 고도화가 필요합니다.
- 스프레드시트 공개 공유를 새로 열지 마세요(소유자만 접근 권장).