Jaeilit

smtp sendgrid 이메일 보내기 본문

TIL

smtp sendgrid 이메일 보내기

Jaeilit 2024. 8. 29. 12:58
728x90

유저분들에게 관련 안내 이메일을 보내야하는 업무가 발생했다.

발송과 발송 과정에서 발생한 문제와 해결 방법을 기록하려고 한다.

 

이 업무는 단발성으로 다시 사용한다거나 유지보수를 할 코드는 아니기에 작업속도에 집중하려고 했고 코드를 이쁘게한다거나 하려고 하지는 않았다.

 

보내는 과정은

1. csv 형식의 파일로 유저 이메일이 포함 된 리스트를 받아서 json 으로 파싱해서 코드로써 이메일을 추려내서 보내려고 했다.

2. 이메일을 발송하고 나서는 sendgird 에도 로그가 남겠지만 노드 자체에서도 발송 후에 로그를 파일로 남기려고 했는데

3. nodejs fs 내장모듈을 사용해서 txt 파일생성해서 간단히 유저 별로 sendmail 에 대한 결과만 남기려고 했다.

 

node 의 express 로 서버를 띄우고 간단히 api 로 이메일을 보내는 코드를 작성했다.

이메일 보내는 방법은 아래 nodejs 문서를 보고 하면 된다.

https://www.twilio.com/docs/sendgrid/for-developers/sending-email/quickstart-nodejs#nodejs

 

Email API Quickstart for Node.js | SendGrid Docs | Twilio

The Twilio SendGrid Node.js helper library supports the current LTS version of Node.js and versions 6, 7, 8, and 10.

www.twilio.com

 

1. csv to json 

https://csvjson.com/csv2json

 

CSV to JSON - CSVJSON

Embed all the functionality of csvjson in any web application with Flatfile. Auto-match columns, validate data fields, and provide an intuitive CSV import experience.

csvjson.com

 

js 코드로 풀어내기 보다는 csv to json 툴을 사용해서 json 으로 변환시키고 디렉토리 내에 보관하였다.

export const getEmail = async (req: Request, res: Response) => {
  const filename = req.query.filename as string;

  if (!filename) {
    return res.json({ message: "not filename" });
  }

const { default: emailList }: { default: CsvEmailFrom[] } = await import(
      `../json/email/${filename}.json`,
      {
        with: { type: "json" },
      }
    );
    
    ....
}

 

 

저장한 json 파일을 불러오도록 코드를 작성했는데 파일이 여러개거나 기록에 따라 파일을 분리하여 사용할 수도 있으니 json 파일을 유동적으로 선택하기 위해서 filename 을 querystring 으로 받도록 하였다.

 

json 파일을 모듈로써 import 하려면 type: module 을 설정한다던가 위에 처럼 with 를 같이 써준다거나 하지않으면 import 시 에러가 발생한다.

 

저는 코드 내에서 최상위에서 Import 해도 되지만 동적 import 로 불러왔다.

 

https://ui.toast.com/posts/ko_20211209

 

자바스크립트에서의 JSON 모듈

ECMAScript 모듈 시스템(`import`와 `export` 키워드)은 기본적으로 자바스크립트 코드만 가져올 수 있다. 하지만 애플리케이션의 설정을 JSON 파일에 저장하는 것이 편리할 때가 많고, 결과적으로 JSON 파

ui.toast.com

 

2. 로그파일 남기기

 

로그 파일에 대한 정보는 sgMail.send 의 결과로 statusCode 와 보낼 때 당시의 유저에 대한 간단한 이메일, uid 에 대한 정보와 utc 시간을 같이 기록했다.

 

export const createLogFile = (data, type:"email"): boolean => {
  const logPath = path.join(__dirname, "./src/log");

  const fileDate = new Date().toISOString();
  const logFileName = `${fileDate}.txt`;

  let payload = [];

  if (Array.isArray(data)) {
    data.forEach((e) => {
      const date = new Date().toISOString();
      payload.push({ ...e, date });
    });
  } else {
    payload = {
      ...data,
      fileDate,
    };
  }

  writeFile(
    `${logPath}/${type}-${logFileName}`,
    JSON.stringify(payload),
    (err) => {
      console.log(err, "err");
      return false;
    }
  );

  return true;
};

// 생성 결과 email-2024-08-28T01:57:12.445Z.txt
// [{"uid":"uid","email":"email","phone":0,"statusCode":202,"date":"2024-08-28T01:57:12.445Z"},

 

3. 딜레이

보내야 할 사용자가 1만명이 넘기에 혹시나 보내다가 노드 서버나 smtp 서버가 다운되는 일이 생길까봐 임의로 지연시간을 주었다.

export const wait = (time: number) => {
  return new Promise((res) => setTimeout(res, time));
};

 

크롤링할 때 짧은 시간내에 많은 요청이나 페이지 진입을 하게 되면 디도스로 의심하여 아이피를 차단한다거나 그런 경우가 있기 때문에 시간이 조금 더 걸리더라도 임의로 지연을 시켜서 피해가는 방법을 사용하고는 하는데, 지금도 이메일을 500명 단위로 짤라서 1초 쉬고 다음 500명 보내는 식으로 임의로 텀을 두었다.

// 500명씩 나누기
const slice = (num: number) => num % 500;

for (let i = 0; i < emailList.length; i++) {
  const user = emailList[i];

  if (!slice(i)) {
    // 갯수마다 delay 텀 두기
    console.log(i, "term");
    await wait(1000);
  }

  const res = await sendEmail(user);
  result.push({ ...user, statusCode: res ? res.statusCode : "fail" });
}

 

500명으로 나눈 값의 나머지가 0이므로 0은 falsy 한 값이니 if문에 slice(i) 을 왜 false 값을 두고 500명일 때 1초씩 지연을 시켰다.

그리고 이메일을 발송했고, 유저들에게 정상적으로 이메일을 발송했다.

 

# 문제 발생

문제의 내용은 노드에서 smtp 서버로 보낼 이메일 html 과 발송 주소에 대한 http 통신 오류는 없었는데 (실제로 로그에도 statusCode가 200으로 기록되어있다) 샌드 그리드에서 유저에게 이메일을 보낼 때 이메일 주소가 정확하지 않다던지, 수신거부라던지, 메일의 용량이 다 찼거나, 어떠한 이유로 반송되는 등 다양한 이유로 전송이 안된 경우가 발생한 것이다.

 

발송과 관련 된 csv 파일인데 1. 시간 순서대로 표기가 되어있었고2. 정상적인 이벤트도 포함되어있어서 양이 엄청 많았고3. 사용 할 수 있는 데이터/ 없는 데이터 구분이 힘들었다.

 

가장 큰 문제는 csv 파일이 커서 json 변환해도 변환 페이지가 뻗어 버리는 문제, 어찌어찌 힘들게 변환해서 json 에 복붙을 했는데 20~30만줄 되는 json 이라 vscode 에서도 파일 형식을 json으로 인식하지 못해서 import 하는데 타입을 읽지못하고 에러가 발생했다. 마지막 문제는 ts가 아니라 js라면 발생하지 않았을 것이다.

 

하지만 원론적으로 너무 크다는건 문제가 되서 파일 용량부터 줄이고자 필요 없는 열 (세로 칼럼) 을 제거하고 csv 에서 필터를 먹여서 에러에 대한 이벤트만 긁어오고 싶었는데 필터를 먹이고 복붙을 해도 중간중간 끼어있는 셀들도 같이 딸려와서 포기했다.

 

긍정적인건 처음에 너무 많은 양을 json 변환에 넣었더니 페이지가 멈췄던 것인데 필요없는 셀을 제거하니 파일 용량이 조금 줄어들고 파싱할 열이 줄어드니까 페이지가 뻗지는 않았다. 이걸 그대로 Json 파일 생성해서 붙여넣었는데, 열도 줄다보니 

발송 누락에 대한 csv 파일

export const getLog = async (req: Request, res: Response) => {
  const { default: log } = await import(`../log/log.json`, {
    with: { type: "json" },
  });

  const logData = log as TLogType[];

  const failedList = logData.filter(({ event }) => {
    const fileConditionEventList = ["bounce", "deferred", "drop"];
    const fileConditionEvent = fileConditionEventList.includes(event);
    return fileConditionEvent;
  });

  const reorganizeData = (list: TLogType[]) => {
    return list.reduce((acc, cur: TLogType) => {
      const [code = "etc", status = "etc"] = cur.reason.split(" ");

      const isCodeValid = !isNaN(+code[0]);
      const isStatusValid = !isNaN(+status[0]);

      const validCode = isCodeValid ? code : "etc";
      const validStatus = isStatusValid ? status : "etc";

      if (!acc[validCode]) {
        acc[validCode] = {};
      }

      if (!acc[validCode][validStatus]) {
        acc[validCode][validStatus] = [];
      }

      acc[validCode][validStatus].push(cur);

      return acc;
    }, {} as any);
  };

 

별도로 get method 로 로그를 편하게 볼 수 있게 하나 만들어두고 사용했다.

로그 파일에서 failedList 실패 리스트를 일단 추려냈고, 추려낸 목록에서 별도의 reorganizedData 라는 함수를 만들어서 아래 사진과 같은 결과로 정리를 해두었다.

 

 

smtp 에러 코드는 오류코드 3자리와 x,y,z로 구분되는 상태코드도 함께 주어진다. 

예시로 421 오류코드에 4.3.0 일 경우 아래의 에러 내용일 확률이 높다.

다른 에러내용들은 여기서 확인 할 수 있는데 이건 gmail 한정이기 때문에 안나오는 코드들이 있을 수 있어서 따로 검색을 해보는 것도 중요하다.

https://support.google.com/a/answer/3726730?hl=ko&ref_topic=1355150&sjid=9907483846050510737-AP

 

Gmail SMTP 오류 및 코드 - Google Workspace 관리자 고객센터

도움이 되었나요? 어떻게 하면 개선할 수 있을까요? 예아니요

support.google.com

 

에러 코드별로 정리 해둔 것들로 수집한 것들을 쭉 검색하고 찾아보면서 대입해봤는데 대충 이런 내용들로 해석 할 수 있었다.

 

정리해보면 500번대는 이메일이 명확하지 않거나 발신지를 못찾거나 비활성화 등 귀책사유가 발신자에게 있는 경우가 대부분이므로 다시 보내도 못받을 확률이 높으니 재발송은 400번대 사용자들에게만 하도록 정했다.

 

## 이메일만 추출하기, 단 중복은 제거하기

const extractEmails = (data: any) => {
    const emails = new Set();

    for (const outerKey in data) {
      const innerData = data[outerKey];
      for (const innerKey in innerData) {
        const items = innerData[innerKey];
        items.forEach((item: any) => {
          if (item.email) {
            emails.add(item.email);
          }
        });
      }
    }

    return Array.from(emails).map((email) => ({ email }));
  };

 

 

에러코드 json 에서 이메일만 추출하도록 함수를 하나 만들었다. too many connection 같은 경우나 이메일 발송이 안되면 재시도를 하는 경우가 있어서 로그에 중복 된 이메일이 존재할 수 있으므로 set 을 사용하여 중복도 제거해주었다.

 

 

이메일은 개인정보이므로 삭제했지만 이런 결과물을 얻을 수 있었고 추출 된 이메일로 기존과 동일한 방식으로 안내 이메일을 전송할 수 있었다.

 

끝으로..

단발성치고는 복잡도가 있고 함수를 좀 많이 만든 느낌이긴하지만 프로그래밍 실력이 도움이되고자 프로그래밍적 사고로 해결하려는 노력을 한 것에 의의를 두려고 한다. 함수들을 작성하면서 내가 원하는대로 배열과 객체를 빠르게 만들지 못할 때 부족함을 많이 느끼고 자료구조와 알고리즘을 꾸준히 해야겠다는 생각도 들었다. 아직 많이 부족하다.

728x90