diff --git a/scripts/apps-script/lead-intake.gs b/scripts/apps-script/lead-intake.gs
index 0a1d30a..46add98 100644
--- a/scripts/apps-script/lead-intake.gs
+++ b/scripts/apps-script/lead-intake.gs
@@ -5,6 +5,13 @@
* 1. 폼 POST 를 받아 구글시트에 행 추가
* 2. 같은 실행 안에서 알림 메일 발송 (sales@o2o.kr, haewonkam@o2o.kr)
*
+ * 보호 장치 (2026-10-01 추가)
+ * - 허니팟: 사람 눈에 안 보이는 칸(HONEYPOT_FIELD)이 채워져 있으면 저장하지 않는다
+ * - 속도 제한: 한 시간 RATE_MAX 건까지. 토큰은 사이트 번들에 그대로 실려 비밀이
+ * 아니므로, 주소와 토큰이 알려져도 피해가 상한 안에 묶이게 한다
+ * - 알림 실패 분리: 메일이 막혀도 행은 남기고 접수로 응답한다. 시트의 '알림' 열에
+ * '보냄' / '실패(메일만)' 을 적어 메일이 안 간 문의를 눈으로 찾을 수 있게 한다
+ *
* 왜 트리거가 아니라 여기서 메일을 보내는가
* 시트 onChange 트리거는 스크립트가 쓴 변경에는 늦거나 누락되는 경우가 있다.
* 행을 추가한 직후 같은 실행에서 보내면 "시트에 들어오면 알림" 을 가장 확실하게 지킨다.
@@ -42,7 +49,24 @@ var NOTIFY_TO = 'sales@o2o.kr,haewonkam@o2o.kr';
/** 행이 쌓일 시트 탭 이름. 없으면 만든다. */
var SHEET_NAME = '문의';
-var HEADERS = ['접수시각', '성함', '회사', '전화', '이메일', '문의내용', '유입', 'User-Agent'];
+var HEADERS = ['접수시각', '성함', '회사', '전화', '이메일', '문의내용', '유입', 'User-Agent', '알림'];
+
+/**
+ * 허니팟. 사람 눈에 보이지 않는 입력칸이라 값이 차 있으면 봇으로 본다.
+ * 사이트 폼(ContactModal)의 숨은 칸 이름과 같아야 한다.
+ */
+var HONEYPOT_FIELD = 'website';
+
+/**
+ * 속도 제한. 한 시간(RATE_WINDOW_SEC) 안에 RATE_MAX 건까지만 받는다.
+ * 토큰은 사이트 번들에 그대로 실려 비밀이 아니다. 누가 주소와 토큰을 알아내도
+ * 피해가 이 상한 안에 묶이게 한다. 메일 하루 한도(Gmail 100통)가 장난 제출로
+ * 소진되면 진짜 문의가 와도 알림이 가지 않기 때문이다.
+ * 고정 창(fixed window) 방식이고, CacheService 라 동시 요청에서 몇 건은
+ * 느슨하게 샐 수 있다. 정확한 과금이 아니라 상한이 목적이라 이 정도로 둔다.
+ */
+var RATE_WINDOW_SEC = 3600;
+var RATE_MAX = 30;
function doPost(e) {
try {
@@ -55,12 +79,24 @@ function doPost(e) {
return json_({ ok: false, error: 'unauthorized' });
}
+ // 허니팟. 봇에게 걸렸다고 알리지 않으려고 ok 를 돌려주되 행은 만들지 않는다.
+ if (trim_(body[HONEYPOT_FIELD])) {
+ console.log('허니팟에 걸림. 저장하지 않는다.');
+ return json_({ ok: true });
+ }
+
// 이름이 비어 있으면 받지 않는다. 사이트 폼도 같은 규칙으로 막는다.
var name = trim_(body.name);
if (!name) {
return json_({ ok: false, error: 'name required' });
}
+ if (rateLimited_()) {
+ // 성공으로 위장하지 않는다. 사이트가 메일 폴백을 띄우게 실패로 돌려준다.
+ console.warn('속도 제한에 걸림: 한 시간 ' + RATE_MAX + '건 초과');
+ return json_({ ok: false, error: 'rate limited' });
+ }
+
var row = [
new Date(),
name,
@@ -70,12 +106,24 @@ function doPost(e) {
trim_(body.message),
trim_(body.source),
e && e.parameter ? trim_(e.parameter.ua) : '',
+ '',
];
+ // 저장이 먼저다. 알림이 실패해도 문의는 남아야 한다.
appendRow_(row);
- notify_(row);
- return json_({ ok: true });
+ // 알림 실패를 요청 실패로 번지게 하지 않는다. 메일이 막혔다고(한도 소진·구글 장애)
+ // 고객 화면에 "전송 실패"가 뜨면, 실제로는 접수됐는데 고객이 모르고 돌아간다.
+ var mailed = true;
+ try {
+ notify_(row);
+ } catch (mailErr) {
+ mailed = false;
+ console.error('알림 메일 실패(문의는 저장됨): ' + mailErr);
+ }
+ markNotified_(mailed);
+
+ return json_({ ok: true, mailed: mailed });
} catch (err) {
// 실패를 삼키지 않는다. 실행 로그에 남기고 사이트에도 실패를 알린다.
console.error('doPost 실패: ' + err);
@@ -88,7 +136,40 @@ function doGet() {
return json_({ ok: true, service: 'webforai lead intake' });
}
+/**
+ * 한 시간 안에 RATE_MAX 건을 넘었는지 본다. 넘었으면 true.
+ * 시각을 창 크기로 나눈 몫을 열쇠로 써서 창이 바뀌면 자동으로 0부터 센다.
+ */
+function rateLimited_() {
+ var cache = CacheService.getScriptCache();
+ var bucket = Math.floor(Date.now() / (RATE_WINDOW_SEC * 1000));
+ var key = 'lead_' + bucket;
+ var n = Number(cache.get(key) || 0);
+ if (n >= RATE_MAX) return true;
+ // 창이 끝난 뒤에도 잠깐 남겨 두어 경계에서 두 번 세지 않게 한다.
+ cache.put(key, String(n + 1), RATE_WINDOW_SEC + 60);
+ return false;
+}
+
function appendRow_(row) {
+ var sh = sheet_();
+ sh.appendRow(row);
+}
+
+/** 방금 넣은 행의 '알림' 칸에 결과를 적는다. 메일이 안 간 문의를 눈으로 찾을 수 있게 한다. */
+function markNotified_(mailed) {
+ try {
+ var sh = sheet_();
+ var last = sh.getLastRow();
+ if (last < 2) return;
+ sh.getRange(last, HEADERS.length).setValue(mailed ? '보냄' : '실패(메일만)');
+ } catch (e) {
+ console.error('알림 표시 실패: ' + e);
+ }
+}
+
+/** 시트를 돌려준다. 없으면 만들고, 헤더가 짧으면 현재 HEADERS 로 맞춘다. */
+function sheet_() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sh = ss.getSheetByName(SHEET_NAME);
if (!sh) {
@@ -98,8 +179,14 @@ function appendRow_(row) {
sh.appendRow(HEADERS);
sh.getRange(1, 1, 1, HEADERS.length).setFontWeight('bold');
sh.setFrozenRows(1);
+ return sh;
}
- sh.appendRow(row);
+ // 열을 추가한 뒤에도 기존 시트의 헤더가 옛 상태로 남아 있지 않게 한다.
+ var width = sh.getLastColumn();
+ if (width < HEADERS.length) {
+ sh.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]).setFontWeight('bold');
+ }
+ return sh;
}
function notify_(row) {
@@ -140,7 +227,15 @@ function trim_(v) {
* 시트에 테스트 행이 들어가고 알림 메일이 오는지 바로 볼 수 있다.
*/
function testRun() {
- var row = [new Date(), '테스트', '설치 확인', '010-0000-0000', 'test@example.com', '설치 확인용 테스트입니다.', 'test', ''];
+ var row = [new Date(), '테스트', '설치 확인', '010-0000-0000', 'test@example.com', '설치 확인용 테스트입니다.', 'test', '', ''];
appendRow_(row);
- notify_(row);
+ var mailed = true;
+ try {
+ notify_(row);
+ } catch (e) {
+ mailed = false;
+ console.error('알림 메일 실패: ' + e);
+ }
+ markNotified_(mailed);
+ console.log('testRun 완료. 메일 발송: ' + (mailed ? '성공' : '실패'));
}
diff --git a/src/components/ContactModal.tsx b/src/components/ContactModal.tsx
index b73d9e8..0a25841 100644
--- a/src/components/ContactModal.tsx
+++ b/src/components/ContactModal.tsx
@@ -32,6 +32,8 @@ export default function ContactModal({ open, onClose, source }: ContactModalProp
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
+ // 허니팟. 사람은 이 칸을 볼 수 없어 늘 빈 값이고, 폼을 자동으로 채우는 봇만 값을 넣는다.
+ const [website, setWebsite] = useState('');
const [state, setState] = useState
+ {COMPANY_LEGAL_NAME} + · + {COMPANY_HQ} + · + {COMPANY_LAB} +
++ 전화{' '} + + {CONTACT_PHONE} + + · + 팩스 {CONTACT_FAX} + · + 이메일{' '} + + {CONTACT_EMAIL} + +