PyCharmで、anyenv + nodenv で構築した ReactとDjango REST Frameworkの両方をデバッグしてみた

今までPyCharmでDjango REST framework(以下、DRF)のデバッグを行ったことはありました。

そんな中、以前 React + TypeScript + DRFでアプリを作りました。

ただ、ReactとDRFをPyCharmだけでデバッグしたことがなかったため、どのように設定すればデバッグできるか試してみました。

 
目次

 

環境

 

DRFを起動する設定

PyCharmでDRFを開発していれば、以下の設定があるはず。。。

項目
Configuration Template Django Server
Name 任意 (backendなど)
Host localhost
Port 8000
Environment variables (デフォルト値)
Python interpreter Projectで使ってる venv のインタプリタ
Add content roots to PYTHONPATH チェックする
Add source roots to PYTHONPATH チェックする

 

yarnでReactを起動する設定

React側は

の2つが必要になります。

まずは、yarnでReactを起動する設定です。

項目
Configuration Template npm
Name 任意
package.json [Reactアプリのpackage.json forntend/package.json のパス
Command start
Node interpreter anyenv + nodenvで入れたNodeのパス (~/.anyenv/envs/nodenv/versions/<version>/bin/node )
Package manager yarn (~/.anyenv/envs/nodenv/versions/<version>/lib/node_modules/yarn)

 
yarnの設定後、実行ボタンをクリックし、Reactが起動すればOKです。

Runのログにはこんな感じで表示されます。

yarn run v1.22.10
$ react-scripts start
...
Starting the development server...

 

JavaScriptデバッグ設定

yarnでReactを起動する設定を行いデバッグ実行したとしても、まだブレークポイントでは停止しません。

そこで、JavaScriptデバッグ設定を追加します。

項目
Configuration Template JavaScript Debug
Name 任意
URL [Reactアプリを起動した時のURL ( http://localhost:3000 など)
Browser Chrome

 
以上で準備ができました。

 

動作確認

流れとしては、

  1. DRFデバッグ起動
  2. Reactを Run で起動
  3. JavaScript Debugを Debug で起動

の順で起動します。

また、DRFとReactの両方にブレークポイントを置いてみます。

 
3. の実行後、拡張機能が何もインストールされていない別のChromeウィンドウが起動します*1

試しに適当な値を入力し、「登録」ボタンを押してみます。 

 
まずはReactのところで止まりました。

f:id:thinkAmi:20210524211014p:plain

 
続行してみると、DRFの方でも止まりました。

f:id:thinkAmi:20210524211153p:plain

 

なお、Reactのコードを修正したところ、Chromeにも反映されました。

f:id:thinkAmi:20210524211405p:plain

 
これで良さそうです。

*1:今回はReactをポート3600で起動していますが、3000でも問題ないはず

rake -T では表示されないRakeタスクについて

Rakefileの中に定義されているRakeタスクを確認しようと、 rake -T したところ表示されないRakeタスクがあったため、メモを残します。

 

環境

  • rake 13.0.2

 

原因

ヘルプに書いてありました。

rake -T では desc がないRakeタスクは表示されないとのことでした。

% rake --help

-T, --tasks [PATTERN]            Display the tasks (matching optional PATTERN) with descriptions, then exit. -AT combination displays all of tasks contained no description.

 

確認

こんな感じでRakefileを作ってみました。

desc "シナノゴールド"
task :shinanogold do
    puts '黄色'
end

desc ""
task :fuji do
    puts ''
end

desc nil
task :orin do
    puts ''
end

task :pinklady do
    puts 'ピンク'
end

 
rake -T の場合、descがあるものだけ表示されました。

% rake -T
rake shinanogold  # シナノゴールド

 
一方、 rake -TA の場合、すべて表示されました。

% rake -AT
rake fuji         # 
rake orin         # 
rake pinklady     # 
rake shinanogold  # シナノゴールド

 
なお、 rake -TA では表示されませんでした。

% rake -TA #=> 何も表示されない

 
他に、 rake -P などでも表示されるようです。
ruby on rails - Why is rake db:migrate:reset not listed in rake -T? - Stack Overflow

今回はgrepを渡さなくても、全件表示されました。

% rake -P
rake fuji
rake orin
rake pinklady
rake shinanogold

 
また、D オプションと組み合わせた時はこんな感じでした。

% rake -D 
rake shinanogold
    シナノゴールド


% rake -AD
rake fuji

rake orin

rake pinklady

rake shinanogold
    シナノゴールド

Delayed Job を使って実行する処理を、RubyMineでデバッグをする

Rails + Delayed Job な環境で、Delayed Job を使って実行する処理がありました。

例えばこんな感じです。

# Delayed Job で実行
# call_heavy_api中で外部APIを呼んでいるが、その処理が重いとする
Task.delay.call_heavy_api({ foo: 'bar' })

 
そんな中、Delayed Job を使って実行する処理 (上記例では call_heavy_api の中身) をデバッグしたくなったところ、同僚からやり方を聞いたため、メモを残します。

 
目次

 

環境

  • macOS
  • RubyMine 2021.1.1

 

設定

Delayed Jobs は Rake タスクなので、以下の設定を RubyMine に行います。

  • Run > Edit Configurations ...
  • Rakeタスクを追加
    • Name: 任意
    • Configurationタブ
      • Task name: jobs:work
    • Bundlerタブ
      • Run the script in context of the bundle (bundle exec) にチェックを入れる
        • bundlerを使っているため

 

実際のアプリで確認

Rails + Delayed Job なアプリを作って確認してみます。

 

Railsアプリを作成
環境構築
# railsの準備
% bundle init

 
Gemfileを作成

# Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails"

 
APIRailsアプリを作成

% rails new delayed_job_debug --api

% cd delayed_job_debug

 
delayed_job_debugのGemfileに、Delayed JobをActiveRecordで使うためのgemを追記。

# delayed_job_debug/Gemfile

# ...
gem 'delayed_job_active_record'

 
インストール

bundle install

 
Delayed Job の準備をします。

% bin/rails g delayed_job:active_record
Running via Spring preloader in process 13171
      create  bin/delayed_job
       chmod  bin/delayed_job
      create  db/migrate/20210517154917_create_delayed_jobs.rb

% bin/rake db:migrate
Running via Spring preloader in process 13208
== 20210517154917 CreateDelayedJobs: migrating ================================
-- create_table(:delayed_jobs)
   -> 0.0035s
-- add_index(:delayed_jobs, [:priority, :run_at], {:name=>"delayed_jobs_priority"})
   -> 0.0013s
== 20210517154917 CreateDelayedJobs: migrated (0.0050s) =======================

 
config/application.rb にて、Active JobのバックエンドとしてDelayed Job を指定

# config/application.rb
config.active_job.queue_adapter = :delayed_job

 

アプリ追加

重いAPIを呼んでいるモデルを作ります。

今回はDBがなくても確認できるため、モデルファイル app/models/task.rb を作るだけにします。

class Task
  class << self
    # 外部APIの処理が重いとする
    def call_heavy_api(params)
      Rails.logger.warn(params)

      'success'
    end
  end
end

 
上記のメソッドを呼んでいるコントローラを作ります。

% bin/rails g controller tasks
Running via Spring preloader in process 13248
      create  app/controllers/tasks_controller.rb
      invoke  test_unit

 
生成されたファイル app/controllers/task_controller.rb に追記します。

class TasksController < ApplicationController
  def create
    Task.delay.call_heavy_api({ foo: 'bar' })

    render json: { status: 'SUCCESS', data: 'done' }
  end
end

 
config/routes へ追記します。

Rails.application.routes.draw do
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
  post 'tasks/' => 'tasks#create'
end

 

Railsアプリの動作確認

実行します。
f:id:thinkAmi:20210519072647p:plain

 

TerminalからcurlでPOSTします。

% curl -X POST -H "Content-Type: application/json" -d '{}' http://127.0.0.1:3500/tasks/

{"status":"SUCCESS","data":"done"}

 
DBに値が入っています。

f:id:thinkAmi:20210519072635p:plain

 
Delayed Job をターミナルから実行します。

% bin/rake jobs:work

Running via Spring preloader in process 32682
[Worker(host:kamijonoMacBook-Pro.local pid:32682)] Starting job worker
[Worker(host:kamijonoMacBook-Pro.local pid:32682)] Job Task.call_heavy_api (id=3) RUNNING
[Worker(host:kamijonoMacBook-Pro.local pid:32682)] Job Task.call_heavy_api (id=3) COMPLETED after 0.0080
[Worker(host:kamijonoMacBook-Pro.local pid:32682)] 1 jobs processed at 12.7021 j/s, 0 failed

 
Sever development log に実行内容も表示されました。

[Worker(host:kamijonoMacBook-Pro.local pid:32682)] Job Task.call_heavy_api (id=3) RUNNING
{:foo=>"bar"}

 
確認できたため、TerminalでDelayed Job をキャンセルしておきます。

 

デバッグ実行
RubyMineの設定

メニューの Run > Edit Configurations... を選択します。

左上の + ボタンを押して Rake を選択し、以下の内容を追加します。

項目
Name 任意 (job)
Configuration - Task name jobs:work
Bundler - Run the script... チェックを入れる

 

動作確認

Terminalから curl で POST し、DBに保存されることを確認します。

% curl -X POST -H "Content-Type: application/json" -d '{}' http://127.0.0.1:3500/tasks/
{"status":"SUCCESS","data":"done"}

 
確認したいところにブレークポイントを設置します。

f:id:thinkAmi:20210519072617p:plain

 
先ほど作成した設定を選択し、デバッグ実行します。

なお、初回は ruby-debug-ide のインストールが提案されるため、インストールします。

f:id:thinkAmi:20210519072600p:plain

 
しばらく待つと、ブレークポイントで止まりました。変数の中身なども確認できます。

f:id:thinkAmi:20210519072544p:plain

 

ソースコード

確認したRailsアプリはGithubに上げました。
https://github.com/thinkAmi-sandbox/delayed_job_debug-sample

React + TypeScript + Django REST frameworkでToDo管理アプリを作ってみた

先日、大岡由佳さんの「りあクト! 第3.1版 (2020年12月26日 初版第1刷発行)」を読みました。書籍では理由や経緯などが書かれており、とてもためになりました。ありがとうございました。

 
そこで、Reactの理解を深めるために何か作ろうと考え、よくあるToDo管理アプリを作ってみたことから、その時のメモを残します。

 

目次

 

環境

  • React 17.0.2
  • TypeScript 4.1.2
  • Python 3.9.5
  • Django 3.2.2
  • Django REST framework 3.12.4
  • PyCharm Professional 2021.1.1

 
今回は各段階を追ってアプリを作成していきます。

アプリのイメージははこんな感じで、段階ごとの見た目はほとんど変わりません。

f:id:thinkAmi:20210510223401p:plain
ToDo管理アプリ

 

やることとやらないこと

やること
  • Reactまわり
    • TypeScriptで書く
    • React Router の5系で書く
    • Hooksを使う
  • Django REST frameworkまわり
    • ModelViewSetを使ってお手軽に書く

 

やらないこと

React & TypeScriptに慣れるため、以下をやらないことにしました。

 

環境構築

Python

最初のうちは Django REST frameworkを使いませんが、忘れないよう venv で環境を作っておきます。

% python --version
Python 3.9.5

% python -m venv env

 

CRAによる、React環境構築
% npx create-react-app frontend --template typescript

We suggest that you begin by typing:

  cd frontend
  yarn start

Happy hacking!

 

ESLint

CRAでESLintはインストールされているため、init します。

% yarn eslint --init

? How would you like to use ESLint? … 
❯ To check syntax, find problems, and enforce code style

? What type of modules does your project use? … 
❯ JavaScript modules (import/export)

? Which framework does your project use? … 
❯ React

? Does your project use TypeScript? › Yes

? Where does your code run? …  (Press <space> to select, <a> to toggle all, <i> to invert selection)
✔ Browser

? How would you like to define a style for your project? … 
❯ Use a popular style guide

? Which style guide do you want to follow? … 
❯ Airbnb: https://github.com/airbnb/javascript

? What format do you want your config file to be in? … 
❯ JavaScript

Checking peerDependencies of eslint-config-airbnb@latest
Local ESLint installation not found.
The config that you've selected requires the following dependencies:

eslint-plugin-react@^7.21.5 @typescript-eslint/eslint-plugin@latest eslint-config-airbnb@latest eslint@^5.16.0 || ^6.8.0 || ^7.2.0 eslint-plugin-import@^2.22.1 eslint-plugin-jsx-a11y@^6.4.1 eslint-plugin-react-hooks@^4 || ^3 || ^2.3.0 || ^1.7.0 @typescript-eslint/parser@latest

? Would you like to install them now with npm? › No

 
エラーが出ました。

ESLint couldn't find the config "airbnb" to extend from. Please check that the name of the config is correct.

The config "airbnb" was referenced from the config file in "".

If you still have problems, please stop by https://eslint.org/chat/help to chat with the team.

error Command failed with exit code 2.

 
足りていないものを、yarnでインストールします。

% yarn add eslint-plugin-react@latest @typescript-eslint/eslint-plugin@latest eslint-config-airbnb@latest eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react-hooks @typescript-eslint/parser@latest

info All dependencies
├─ @typescript-eslint/eslint-plugin@4.22.1
├─ @typescript-eslint/parser@4.22.1
├─ eslint-config-airbnb-base@14.2.1
├─ eslint-config-airbnb@18.2.1
├─ eslint-plugin-import@2.22.1
├─ eslint-plugin-jsx-a11y@6.4.1
├─ eslint-plugin-react-hooks@4.2.0
└─ eslint-plugin-react@7.23.2

 
設定ファイルを追加・更新します。

全部載せると長いので、リンクをはっておくだけにします。

 

Prettier

インストールします。

% yarn add -D prettier eslint-config-prettier

info All dependencies
├─ eslint-config-prettier@8.3.0
└─ prettier@2.2.1

»  TypeSync v0.8.0
✔  2 new typings added.

📦 frontend — package.json (2 new typings added, 0 unused typings removed)
├─ + @types/prettier
└─ + @types/testing-library__jest-dom

✨  Go ahead and run npm install or yarn to install the packages that were added.


% yarn

success Saved lockfile.

 
設定ファイルを追加します。

 
ESLintとPrettierが衝突していないか確認します。

% npx eslint-config-prettier 'src/**/*.{js,jsx,ts,tsx}'
No rules that are unnecessary or conflict with Prettier were found.

 

package.jsonのscriptにeslintやprettierを追加します。

"fix": "npm run -s format && npm run -s lint:fix",
"format": "prettier --write --loglevel=warn 'src/**/*.{js,jsx,ts,tsx,gql,graphql,json}'",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}",
"lint:fix": "eslint --fix 'src/**/*.{js,jsx,ts,tsx}'",
"lint:conflict": "eslint --print-config .eslintrc.js | eslint-config-prettier-check",
"preinstall": "typesync"

 

PyCharmの設定

今回はProfessional版を使うので、prettier pluginを追加します。

その後、以下の設定を行います。

 

ESLintの設定

Languages & Frameworks > JavaScript > Code Quality Tools > ESLint

[x] Automatic ESLint configuration

Run for files: (デフォルトのまま)
{**/*,*}.{js,ts,jsx,tsx,html,vue}

[ ] Run eslint --fix on save

 

Prettierの設定

Languages & Frameworks > JavaScript > Prettier

Prettier package: <プロジェクトの node_modules にある prettierを指定>

Run for files: (デフォルトのまま)
{**/*,*}.{js,ts,jsx,tsx}

[x] On code reformat
[x] ON save

 
Prettierの設定ができているかは、index.tsx

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root'),
);

ワンライナーにした状態で保存した時に、上記のように修正されればOKです。

 

huskyとlint-staged

インストールします。

% yarn add -D husky lint-staged

 
package.jsondevDependencies の下に追加します。

"husky": {
  "hooks": {
    "pre-commit": "lint-staged"
  }
},
"lint-staged": {
  "src/**/*.{js,jsx,ts,tsx}": [
    "prettier --write --loglevel=warn",
    "eslint --fix"
  ],
  "src/**/*.{json}": [
    "prettier --write --loglevel=warn"
  ]
}

 

ディレクトリ構成

今回は段階を追ってアプリを作成していくため、

  • srcの下に component を作る
  • componentの下に、 v1 のような各段階ごとのcomponentを入れるディレクトリを作る

のようなディレクトリ構造としました。

 

第1段階:入力フォームとリスト表示を1つのコンポーネントに入れたバージョン

まずは入力フォームとリスト表示を1つのコンポーネントに入れてみます。

React Routerと使用するバージョンについて

各段階でURLを変えるため、React Routerを使います。

りあクト!では v5 と v6 の話がありましたが、今のところまだ v6 はリリースされていないため、今回は v5 系を使います。

 

インストール
% yarn add react-router react-router-dom

% yarn

 

App.tsx の実装

App.tsx にReact Routerの実装を行います。

今回は

な書き方とします。

import React, { VFC } from 'react';
import './App.css';
import { Switch, Route, Redirect } from 'react-router';
import V1Home from 'components/v1/V1Home';
import Home from './components/Home';

const App: VFC = () => (
  <div className="container">
    <Switch>
      <Route exact path="/">
        <Home />
      </Route>

      <Route exact path="/v1">
        <V1Home />
      </Route>

      <Redirect to="/" />
    </Switch>
  </div>
);

export default App;

 

V1Home.tsx

React Routerから呼ばれるコンポーネントです。この中で V1FormList コンポーネントを呼び出しています。

import { VFC } from 'react';
import V1FormList from './V1FormList';

const V1Home: VFC = () => (
  <>
    <h1>タスクリスト</h1>
    <V1FormList />
  </>
);

export default V1Home;

 

V1FormList.tsx

全体像はこんな感じです。以降で詳しく見ていきます。

import { FormEvent, useEffect, useRef, useState, VFC } from 'react';

type Task = {
  id: number;
  content: string;
};

const V1FormList: VFC = () => {
  const [content, setContent] = useState('');
  const [tasks, setTasks] = useState<Task[]>([]);

  const taskRef = useRef<HTMLInputElement | null>(null);
  useEffect(() => {
    taskRef.current?.focus();
  });

  const addTask = (e: FormEvent) => {
    e.preventDefault();
    setTasks([...tasks, { id: tasks.length, content }]);
    setContent('');
  };

  return (
    <>
      <form onSubmit={addTask}>
        <input
          type="text"
          name="task"
          ref={taskRef}
          onChange={(e) => setContent(e.target.value)}
          value={content}
        />
        <button type="submit">登録</button>
      </form>

      <ul>
        {tasks.map((task) => (
          <li key={task.id}>{task.content}</li>
        ))}
      </ul>
    </>
  );
};

export default V1FormList;

 

State Hookの利用

上記では、input要素で入力した値を保管するために State Hook を使っています。
ステートフックの利用法 – React

 
まずは入力値を入れておく State Hookを用意します。

const [content, setContent] = useState('');

 
次に、入力した値はリストとして表示したいため、リスト表示用の State Hook も用意します。

Stateの型は上記の content を配列で持つ string[] でも良いのですが、リスト表示する時の key を与えたいため、型を用意します。
リストと key – React

type Task = {
  id: number;
  content: string;
};

この型を使った State を用意します。

const [tasks, setTasks] = useState<Task[]>([]);

 
フォームで登録ボタンを押した時に、リスト表示用の State に値を追加する関数を用意します。

const addTask = (e: FormEvent) => {
  e.preventDefault();
  setTasks([...tasks, { id: tasks.length, content }]);
  setContent('');
};

ここで、addTask関数の引数は FormEvent型 を想定しています。
Forms and Events | React TypeScript Cheatsheets

また、 preventDefault() を使ってデフォルトのイベントをキャンセルし、バックエンドにリクエストが飛ばないようにします。

 
また、配列のStateでは、Array.pushが使えません。

そのため、スプレッド構文を使って、Stateに値を追加しています。

setTasks([...tasks, { id: tasks.length, content }]);

 
最後の setContent(''); は、リスト表示用Stateに値を入れたら画面のinput要素をクリアするために用意しています。

 
あとは

  • formの onSubmit に、追加用関数 addTask を割り当て
  • inputの onChange に、content Stateの更新処理を追加
  • inputの value に、 onChangeで更新した値を割り当て
  • ulの中で、 tasks Stateのデータを元に表示

とします。

return (
  <>
    <form onSubmit={addTask}>
      <input
        type="text"
        name="task"
        onChange={(e) => setContent(e.target.value)}
        value={content}
      />
      <button type="submit">登録</button>
    </form>

    <ul>
      {tasks.map((task) => (
        <li key={task.id}>{task.content}</li>
      ))}
    </ul>
  </>
);

 

フォーカスの指定

ここで、「登録ボタンを押した後、input要素にフォーカスを戻したい」という要望がある場合、どのように実装すればよいかを調べてみました。

りあクト!Ⅱの p192 より、Reactでは、 useRefuseEffect を使うと良さそうでした。

const taskRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
  taskRef.current?.focus();
});

 
ちなみに、型定義や useRefの引数から null を抜くと、以下のエラーとなりました。

TS2322: Type 'MutableRefObject<HTMLInputElement | undefined>' is not assignable to type 'LegacyRef<HTMLInputElement>| undefined'.
Type 'MutableRefObject<HTMLInputElement | undefined>' is not assignable to type 'RefObject<HTMLInputElement>'.
Types of property 'current' are incompatible.
Type 'HTMLInputElement | undefined' is not assignable to type 'HTMLInputElement | null'.
Type 'undefined' is not assignable to type 'HTMLInputElement | null'.

 
この taskRef を input に紐付けます。

<input
  ..
  ref={taskRef}
  ..
/>

 

動作確認

以下でReactが起動しますので、タスクを色々いれてみます。

% yarn start

 

これで第1段階が完成しました。コミットは こちら です。

 

第2段階:FormとListのコンポーネントを分離する

第1段階では ListForm.tsx の中に Form と List が含まれていました。

次はそれらを分離してみます。

 

Stateをどこに持たせるか

分離するにあたり、 State をどこに持たせるかを考えます。

content は Form でしか使っていませんが、 tasks は Form と List の両方で使っています。

Reactの流儀によると、

共通の親コンポーネントか、その階層構造でさらに上位の別のコンポーネントが state を持っているべきである

https://ja.reactjs.org/docs/thinking-in-react.html#step-4-identify-where-your-state-should-live

とのことなので、今回の共通の親である V2Home.tsxtasks Stateを持たせます。

const V2Home: VFC = () => {
  const [tasks, setTasks] = useState<Task[]>([]);

  return (
    <>
      <h1>タスクリスト (v2)</h1>
      <V2Form tasks={tasks} setTasks={setTasks} />
      <V2List tasks={tasks} />
    </>
  );
};

 

型定義

ここで、 Task 型はどのコンポーネントでも使うので、 src/components/Task.ts に型定義を置いておきます。

export type Task = {
  id: number;
  content: string;
};

なお、Task typeをV2Home.tsxに持たせ、

// Home
import V2Form from './V2Form';

// Form
import { Task } from './V2Home';

のように import したところ

ESLint: Dependency cycle detected.(import/no-cycle)

というESLintのエラーが出ました。

 
あとは、FormとListを分離します。

 

V2Form.tsx

このコンポーネントがpropsで受け取る型は

type Props = {
  tasks: Task[];
  setTasks: Dispatch<SetStateAction<Task[]>>;
};

とします。

なお、setTasksの型 Dispatch<SetStateAction<Task[]>> については、PyCharmで提案されたものをそのまま入れました。

Formの全体像はこちら。

type Props = {
  tasks: Task[];
  setTasks: Dispatch<SetStateAction<Task[]>>;
};

const V2Form: VFC<Props> = ({ tasks, setTasks }) => {
  const [content, setContent] = useState('');
  const taskRef = useRef<HTMLInputElement | null>(null);
  useEffect(() => {
    taskRef.current?.focus();
  });

  const addTask = (e: FormEvent) => {
    e.preventDefault();
    setTasks([...tasks, { id: tasks.length, content }]);
    setContent('');
  };

  return (
    <>
      <form onSubmit={addTask}>
        <input
          type="text"
          name="task"
          ref={taskRef}
          onChange={(e) => setContent(e.target.value)}
          value={content}
        />
        <button type="submit">登録</button>
      </form>
    </>
  );
};

 

V2List.tsx

Listも、分離したもの + propsの型定義を用意します。

type Props = {
  tasks: Task[];
};

const V2List: VFC<Props> = ({ tasks }) => (
  <>
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>{task.content}</li>
      ))}
    </ul>
  </>
);

これで第2段階が完成しました。コミットは こちら です。

 

第3段階:Django REST framework製バックエンドと連携する

ここまでは入力した値は State にしか保存していませんでした。そのため、リロードしたら消えてしまいます。

そこで次はDBを持つバックエンドを用意して、データを永続化します。

 

Django REST framework アプリの作成

今回はReactの習得がメインなので、バックエンドは慣れている Django REST framework (以降、DRF) で作ります。

今回はCRUDができれば良いので、ModelViewSetを使ってお手軽に作ります。

詳しい実装は省略しますが、気になる場合は以下の書籍を参考にしてください。
現場で使える Django REST Framework の教科書(第2版) - あきよこブログ(akiyoko blog) - BOOTH

 
DRFをインストールします。

(env) % pip install django djangorestframework
...
Successfully installed asgiref-3.3.4 django-3.2.2 djangorestframework-3.12.4 pytz-2021.1 sqlparse-0.4.1

 
Djangoプロジェクトとアプリを作ります。

(env) % django-admin startproject config .
(env) % python manage.py startapp task
(env) % python manage.py startapp apiv1

 

なお、DRFとReactを連携する際、DRFとReactが

  • Reactはポート3000で起動
  • DRFはポート8000で起動

するため、別オリジンとなります。

そこで、 django-cors-headers を追加でインストールと設定を行い、ReactからのDRFアクセスを許可します。
adamchainz/django-cors-headers: Django app for handling the server headers required for Cross-Origin Resource Sharing (CORS)

(env) % pip install django-cors-headers

 
ちなみに、手元にあるDRFの教科書(2019/9/22 v1.0.0)で使っていた 3.1.0では、別オリジンの設定名は CORS_ORIGIN_WHITELIST でしたが、今回使った 3.7.0 では CORS_ALLOWED_ORIGINS に変わっていました。

CORS_ALLOWED_ORIGINS = (
    'http://localhost:3000',
    'http://127.0.0.1:3000',
)

 
 
PyCharmにDjangoを認識させます。

# Languages & Frameworks > Django

[x] Enable Django support

Django project root : <root dir>
Settings: config/settings.py
Manage script: manage.py

 

準備ができたため、 このコミットのようなアプリを作ります。

アプリを作り終えたらマイグレーションします。

(env) % python manage.py makemigrations

(env) % python manage.py migrate

 
あとはDRFを起動し、Browsable APIhttp://localhost:8000/api/v1/tasks/ にアクセス、データの登録などができることを確認します。

 

axiosのインストール

ReactからDRFへリクエストを飛ばすために、axiosをインストールします。

% yarn add axios

% yarn

 

FormコンポーネントDRFと連携する

連携するため、

  • 初期値のロード
  • 登録ボタンを押すと、DRF側へリクエストが飛んで保存

の2つを実装します。

 

初期値のロード

初期値をロードする Effect Hook を用意します。
React Hooksでデータを取得する方法 - Qiita

useEffect(() => {
  console.log('called!');
  const fetchData = async () => {
    const response: AxiosResponse<ApiGetResponse> = await axios.get(API_URL);
    setTasks(response.data);
  };

  void fetchData();
}, [setTasks]);

 

登録ボタンを押すと、DRF側へリクエストが飛んで保存

ボタンを押したらリクエストを飛ばすため、addTask関数の中に処理を追加します。
reactjs - How to POST request using axios with React Hooks? - Stack Overflow

axiosを使って非同期でリクエストを飛ばすため、 async/awaitも使います。

const addTask = async (e: FormEvent) => {
  e.preventDefault();

  const requestData = { content };
  try {
    const response: AxiosResponse<ApiPostResponse> = await axios.post(
      API_URL,
      requestData,
    );

    setTasks([...tasks, { id: response.data.id, content }]);
    setContent('');
  } catch (error) {
    alert('エラーでした');
  }
};

 
なお、axiosを使う時の型ですが、AxiosResponseは

import axios, { AxiosResponse } from 'axios';

と、axios から importします。

また、axiosのレスポンスのdataの型は、

type ApiGetResponse = {
  id: number;
  content: string;
}[];

type ApiPostResponse = {
  id: number;
  content: string;
};

と用意し、

const response: AxiosResponse<ApiPostResponse> = await axios.post()

のように渡します。

 
これで第3段階も完成です。コミットは3つに分かれています。

 

第4段階:削除ボタンを付ける

今までは作成するだけだったので、削除ボタンを付けてみます。

Listの方に付けるため、まずは削除する関数を用意します。

removeTask関数の中では、

  • axiosで削除
  • tasks Stateに、引数で渡された task.id と一致しないものだけセット

します。

const removeTask = async (taskId: number) => {
  try {
    await axios.delete(`${API_URL}${taskId}/`);
    setTasks(tasks.filter((task) => task.id !== taskId));
  } catch (error) {
    alert('エラーでした');
  }
};

 
そして、Listのコンポーネントの中でも setTasks Hookを使うようになったため、型定義とpropsを追加します。

type Props = {
  tasks: Task[];
  setTasks: Dispatch<SetStateAction<Task[]>>;
};

const V4List: VFC<Props> = ({ tasks, setTasks }) => { ... }

 
これで完成です。コミットは こちら

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/todolist_by_react_ts_drf

また、今回実装した機能がまとまっているプルリクはこちらです。
https://github.com/thinkAmi-sandbox/todolist_by_react_ts_drf/pull/1

Python + openpyxlを使って、月末日を除く五十日始まりのカレンダーを作成してみた

Pythonの標準モジュール calendar では、カレンダーを作るための便利な関数が用意されています。
calendar --- 一般的なカレンダーに関する関数群 — Python 3.9.4 ドキュメント

例えば、 monthcalendar で年月を指定すると週ごとに日付リストが得られるため、これを元にしたカレンダーが作りやすいです。

>>> import calendar
>>> import pprint

>>> pprint.pprint(calendar.monthcalendar(2021, 4))
[[0, 0, 0, 1, 2, 3, 4],
 [5, 6, 7, 8, 9, 10, 11],
 [12, 13, 14, 15, 16, 17, 18],
 [19, 20, 21, 22, 23, 24, 25],
 [26, 27, 28, 29, 30, 0, 0]]

 
ただ、この calendar モジュールではカレンダーの初日が1日に固定されます。

そんな中、五十日(ごとおび)始まりのカレンダーを作る機会があったため、メモを残します。

 

目次

 

環境

 

仕様

Wikipediaによると、五十日とは

五十日(ごとおび)とは、毎月5日・10日・15日・20日・25日と、30日または月末日のことである。

五十日 - Wikipedia

とのことです。

月により月末日が変わる点が厄介でしたが、今回は「月末の五十日は月初の五十日と1日しか異ならないし、月末始まりのカレンダーは使わない」ということだったので、月末の五十日は仕様対象外としました。

最終的には

  • カレンダーの初日に当たる年月日をコマンドラインから入力
  • カレンダーの罫線や週の数は不変のため、テンプレートとしてExcelファイルを用意し、そこに日付を埋めていく
  • 29日以降の日付を入力したらエラーにする
    • 月末の扱いが手間なので、一番日数が少ない2月に合わせた

という仕様としました。

なお、Excel製テンプレートはこんな感じです。

f:id:thinkAmi:20210429130754p:plain

 

プログラムの構成

大きく分けて

  • 年月日の入力
  • カレンダー用データの作成
  • Excelへの埋め込み

の3つの構成としました。

 

年月の入力

ここは普通に入力を受け取るだけです。

def input_values():
    print('開始する年を入力してください')
    yyyy = input()

    print('開始する月を入力してください')
    mm = input()

    print('開始する日を入力してください')
    dd = input()

    try:
        if int(dd) > 28:
            print('29日以降はサポート対象外です')
            return None
        return datetime.datetime(int(yyyy), int(mm), int(dd))
    except:
        print('日付ではありません')
        return None

 

カレンダー用データの作成

扱いやすそうだった、標準モジュール calendar.monthcalendar の戻り値の形式に合わせてデータを作成します。

まずは「開始日」〜「翌月の開始日-1日」を作成します。

# カレンダーの初日から最終日までの日付を作る
dates = []
while True:
    dates.append(current_date)
    current_date += datetime.timedelta(days=1)

    # ここまでで翌月の当日になっていたら、処理を終了する
    if start_at == current_date.day:
        break

# ここまでの結果 (2021/7/10始まりの場合)
# [
#   datetime.datetime(2021, 7, 10, 0, 0), ... , datetime.datetime(2021, 8, 9, 0, 0)
# ]

 
続いて、テンプレートよりカレンダーは日曜日始まりとする調整を行います。

カレンダー初日が日曜日でない場合は、標準モジュールのようにダミーの値を先頭に挿入します。今回は None を挿入しました。

# 先頭は日曜日始まり
weekday_of_first_day = dates[0].weekday()
if weekday_of_first_day != 6:  # 日曜日以外
    # 日曜日始まりでない場合、開始日の曜日以前はダミー(None)を入れておく
    for _ in range(weekday_of_first_day + 1):
        dates.insert(0, None)

# ここまでの結果
# [
#  None, None, None, None, None, None,
#  datetime.datetime(2021, 7, 10, 0, 0), ... , datetime.datetime(2021, 8, 9, 0, 0)
# ]

 
ここまででひと月分のカレンダーデータができました。

テンプレートに埋めやすくするため、カレンダーデータを週ごと(7要素ごと)に分割します。

なお、月によっては最後の週だけが7つの要素にならないことがあるため、以下の記事を参考に itertools.zip_longest を使って足りない分はNoneを埋め込みます。
リストをn個ずつのサブリストに分割 (Python) - おぎろぐはてブロ

dates_by_calendar = [item for item in itertools.zip_longest(*[iter(dates)] * 7)]

# ここまでの結果
# [
#  (None, None, None, None, None, None, datetime.datetime(2021, 7, 10, 0, 0)),
#  (datetime.datetime(2021, 7, 11, 0, 0), ... , datetime.datetime(2021, 7, 17, 0, 0)),
#  ...
#  (datetime.datetime(2021, 8, 8, 0, 0), datetime.datetime(2021, 8, 9, 0, 0), None, None, None, None, None)
# ]

 
後はこれを12ヶ月分繰り返します。

全体像はこんな感じです。

def create_calendar(current_date):
    start_at = current_date.day
    calendar = []

    for _ in range(12):
        # カレンダーの初日から最終日までの日付を作る
        dates = []
        while True:
            dates.append(current_date)
            current_date += datetime.timedelta(days=1)

            # ここまでで翌月の当日になっていたら、処理を終了する
            if start_at == current_date.day:
                break

        # 先頭を埋める
        # 先頭は日曜日始まり
        weekday_of_first_day = dates[0].weekday()
        if weekday_of_first_day != 6:  # 日曜日以外
            # 日曜日始まりでない場合、開始日の曜日以前はダミー(None)を入れておく
            for _ in range(weekday_of_first_day + 1):
                dates.insert(0, None)

        # リストを一週間ごと(7要素ごと)のリストへ分割し、最後の要素が足りない場合はNoneを入れる
        dates_by_calendar = [item for item in itertools.zip_longest(*[iter(dates)] * 7)]
        calendar.append(dates_by_calendar)

    return calendar

 

Excelへの埋め込み

openpyxlを使って埋め込みます。

Excleのセル数に合わせて細かいことをしていますが、コメント通りです。

def to_excel(calendar):
    wb = openpyxl.load_workbook('template_cal.xlsx')

    sheet = wb.copy_worksheet(wb['テンプレート'])
    sheet.title = '結果'

    plot(sheet, calendar)

    wb.save(f'cal_{datetime.datetime.now().strftime("%Y%m%d%H%M%S")}.xlsx')


def plot(sheet, calendar):
    for i, weeks_of_month in enumerate(calendar, 1):
        is_first_time = True

        mod_col = i % 3
        if mod_col == 1:  # 左端のカレンダーに入力
            pos_col = 2
        elif mod_col == 2:  # 中央のカレンダーに入力
            pos_col = 11
        else:
            pos_col = 20  # 右のカレンダーに入力

        if 1 <= i <= 3:  # 1行目のカレンダーに入力
            pos_row = 4
        elif 4 <= i <= 6:  # 2行目のカレンダーに入力
            pos_row = 13
        elif 7 <= i <= 9:  # 3行目のカレンダーに入力
            pos_row = 22
        else:
            pos_row = 31  # 4行目のカレンダーに入力

        # 一ヶ月のうちの一週間分の日付を取得する
        for row_index, current_week in enumerate(weeks_of_month):
            # 日付をセルに設定する
            for col_index, current_date in enumerate(current_week):
                if current_date:  # ダミーは印字しない
                    # そのカレンダーに初めて日付を設定する場合、タイトルも設定する
                    if is_first_time:
                        sheet.cell(row=pos_row-2, column=pos_col, value=f'{current_date.month}月')
                        is_first_time = False

                    sheet.cell(
                        row=pos_row+row_index, column=pos_col+col_index, value=current_date.day
                    )

 
これで必要な関数はできたため、 main 関数でそれぞれを呼び出せば完成です。

def main():
    input_date = input_values()
    if not input_date:
        print('終了します')
        return

    calendar = create_calendar(input_date)
    to_excel(calendar)
    print('作成しました')

 

動作確認

実際に動かしてみます。

% python run.py     
開始する年を入力してください
2021
開始する月を入力してください
7
開始する日を入力してください
10
作成しました

 
できあがりです。

f:id:thinkAmi:20210429132235p:plain

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi/gotobi_calendar

SendGridでは、受信者メールアドレスの大文字小文字はどうなるか試してみた

メールアドレスの形式について調べる機会があったため、RFC5321(日本語訳)を見たところ、

動詞と引数の値(例えば RCPT コマンドにおける "TO:" または "to:" や拡張名キーワード)は大文字・小文字を区別されないが、メールボックスの local-part の指定が唯一の例外である(SMTP 拡張は大文字・小文字を区別する要素を明示的に規定してもよい)。つまり、コマンド動詞、メールボックスの local-part 以外の引数、自由形式のテキストは、その意味に影響を与えることなく、大文字、小文字、または大文字・小文字の任意の組み合わせで符号化されてよい(MAY)ということである。メールボックスの local-part は大文字・小文字を区別されなければならない(MUST)。したがって SMTP 実装は、メールボックスの local-part の大文字・小文字が保持されるよう注意しなければならない(MUST)。具体的にいうと、一部のホストにとってユーザー "smith" はユーザー "Smith" と異なるということである。しかしながら、メールボックスの local-part の大文字・小文字の区別の濫用は相互運用性を妨げるため、推奨されない。メールボックスドメインは通常の DNS 規則にしたがい、大文字・小文字を区別しない。

http://srgia.com/docs/rfc5321j.html#p2.4

とありました。

また、

「仕様としては、"区別する"だけど、メールボックスの運用実態としては"区別しない"運用になっている」

http://babyp.blog55.fc2.com/blog-entry-982.html

との記載もありました。

 
SendGridでどうなるか調べたところ、それらしいドキュメントが見当たりませんでした。

そこで今回、受信者のメールアドレスの大文字小文字がどうなるか試してみました。

 

目次

 

環境

前回の記事同様、Pythonスクリプトで試したため、以下の環境となります。

  • Python 3.8
  • sendgrid 6.6.0
  • smtpapi 0.4.7
  • python-dotenv 0.16.0

 

試したソースコード

SendGridのSMTPサーバを使って、以下のような 'send' 関数を用意しました。

後は呼び出し元で、受信者のメールアドレスを変えてみればよいとしました。

import os
import smtplib
from email.mime.text import MIMEText

from smtpapi import SMTPAPIHeader
from dotenv import load_dotenv
load_dotenv()


def send(to_email):
    server = create_smtp_server()

    from_email = os.environ['FROM_EMAIL']
    to_email = [to_email]

    body = 'hello'
    message = MIMEText(body)
    message['From'] = from_email
    message['TO'] = ','.join(to_email)

    message['Subject'] = 'メールドロップテスト'

    server.sendmail(from_email, to_email, message.as_string())
    server.quit()


def create_smtp_server():
    host = 'smtp.sendgrid.net'
    port = 587
    user = 'apikey'
    password = os.environ['SENDGRID_API_KEY']
    server = smtplib.SMTP(host, port)
    server.starttls()
    server.login(user, password)
    return server


if __name__ == '__main__':
    main()

 

正常に届くメールについて

存在するメールアドレスをすべて大文字で指定した FOO@EXAMPLE.COM 宛にメールを送信したところ、

となっていました。

 
また、 Foo@Example.com と大文字小文字を混ぜたところ、

となっていました。

正常に届くメールはドメイン部分は全て小文字になるようです。

 

Event Webhookで受け取った時の値について

次に、存在しないメールアドレス notfound@example.comBounceした時、SendGridのEvent Webhookではどのような値が返ってくるかを調べてみました。

メールアドレスに対し

  • すべて大文字
  • すべて小文字
  • 大文字小文字混在

の条件でそれぞれ送信したところ、

{status=5.1.1, smtp-id=xxx, ip=149.72.71.211, tls=0.0, 
reason=550 5.1.1 <notfound@example.com>: Recipient address rejected: User unknown in virtual mailbox table,
email=notfound@example.com, 
sg_event_id=xxx, event=bounce, 
sg_message_id=xxx, 
timestamp=1.617030019E9, 
type=bounce}

のようにすべて小文字 ( notfound@example.com ) となっていました。

 

まとめ

上記より、現時点のSendGridでは

  • 届くメール
    • ユーザ部分はそのまま・ドメイン部分は小文字化にて、メールが送信される
  • 届かないメール
    • Event Webhookのメールアドレスは、すべて小文字化される

のようです。

 

ソースコード

Githubに上げました。 lettercase の中が今回のファイルです。
https://github.com/thinkAmi-sandbox/sendgrid_event_webhook-sample

SendGridのEvent Webhookでメールを識別するため、X-SMTPAPIヘッダのUnique Argumentsを使ってみた

SendGridにはEvent Webhookがあり、メール送信のイベントをWebhookとして拾うことができます。

 
ただ、Webhookで取得できるデフォルトの項目が送信先メールアドレスや発生日時などに限られていました。

項目を増やせないかを調べたところ、

などがありました。

このうち、Categoriesについては

カテゴリはUS-ASCII文字セットを使った7bitエンコードを使用する必要があります。 カテゴリはメッセージをグループ化するために使用されます。メッセージにユニークなデータや識別子を付加したい場合、代わりにUnique Argumentsを使用してください。

https://sendgrid.kke.co.jp/docs/API_Reference/SMTP_API/categories.html

とのことです。

できれば日本語の件名を識別に使いたかったことから、X-SMTPAPIヘッダのUnique Argumentsを試してみました。

 

目次

 

環境

 

Event Webhook受信アプリの作成

アプリの基盤

Event Webhookの受信はWebアプリを用意すればよかったので、今回はお手軽に

としました。

 

Basic認証について

SendGridのEvent WebhookではBasic認証をサポートしています。

Event Webhook は HTTP のBasic認証をサポートしています。この認証を使用する場合は、Settings画面で、認証情報を含めたURLを HTTP Post URL に指定してください。

http(s)://username:password@domain/foo.php

https://sendgrid.kke.co.jp/docs/API_Reference/Webhooks/event.html#-Setup

一方、Google Apps ScriptのWebアプリではヘッダ情報を取得できないことから、Basic認証のサポートは難しそうです。
https://issuetracker.google.com/issues/67764685

ただ今回はお手軽なアプリのため、Basic認証はかけずに使ってみます。

 

Google Apps ScriptによるWebアプリの作成

doPost() 関数を用意し、Webアプリとして公開します。

公開した際にリクエスト用のURLが発行されますので、これを使います。

なお、今回はGoogleスプレッドシートの所有者として動かし、誰でもアクセスできるWebアプリとします。

 
用意したWebアプリは以下のような感じです。

 

// main.gs

function doPost(e) {
  const id = 'YOUR SHEET ID';
  const ss = SpreadsheetApp.openById(id);
  const sheet = ss.getSheetByName('シート1');
  
  // 一行目に最終更新日時をセット
  sheet.getRange('A1').setValue(Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss'))
  
  const rangeValues = sheet.getRange('A:A').getValues();
  let targetRow = rangeValues.filter(String).length + 1;  // A列の最終行の次の行へ入力
  
  const posts = JSON.parse(e.postData.getDataAsString());
  for (const post of posts) {
    sheet.getRange(`A${targetRow}`).setValue(toDateTime(post.timestamp));
    sheet.getRange(`B${targetRow}`).setValue(post.email);
    sheet.getRange(`C${targetRow}`).setValue(post.event);
    sheet.getRange(`D${targetRow}`).setValue(post);
  
    targetRow++;
  }

  // 2023/07/30 13:04 追記 ここから
  // コメントいただいた通り、この行は不要
  //const firstRecord = pj[0]
  // 2023/07/30 13:04 追記 ここまで  

  // SendGridのEvent WebhookではHTTPステータスコードさえあれば良いけど念のため
  const result = {
    message: "hello"
  }
  
  const response = ContentService.createTextOutput();
  response.setMimeType(ContentService.MimeType.JSON);
  response.setContent(JSON.stringify(result));
  
  return response;
}
  
// Unixタイムスタンプを日時に直す
function toDateTime(unixTimestamp) {
  const dtFormat = new Intl.DateTimeFormat('ja-JP', {
    dateStyle: 'medium',
    timeStyle: 'medium',
    timeZone: 'Asia/Tokyo'
  });
    
  return dtFormat.format(new Date(unixTimestamp * 1e3));
}

 

SendGridの設定

Event Webhookの設定

ドキュメントに従い、Event Webhookの設定をします。
Event Webhook - ドキュメント | SendGrid

HTTP Post URL には、上記のGoolge Apps Scriptで作成したWebアプリのURLを指定します。

その後、 Test Your Integration をクリックし、Googleスプレッドシートにテストデータが記載されればOKです。

 
あとは忘れずに ENABLE にした後 Save します。

なお、Event Webhookの設定が反映されるまでは少々時間がかかります。

そのため、設定後即バウンスするメールを送信してもEvent Webhookが動作しないことがあります。

 

APIキーの設定

送信用のAPIキーを作成します。
APIキーの管理 - ドキュメント | SendGrid

 

X-SMTPAPIヘッダを持つメールをSMTP送信

準備ができたので、SMTP APIX-SMTPAPI ヘッダを持つメールを送信してみます。
Integrating with the SMTP API - ドキュメント | SendGrid

X-SMTPAPIヘッダを自分で作成するのは手間なため、SendGridの公式ライブラリを使います。
APIライブラリ - ドキュメント | SendGrid

今回はPythonからSMTP送信するため、 smtpapi-python を使います。
https://github.com/sendgrid/smtpapi-python

今回は独自の文字列とメールの件名を X-SMTPAPI へ入れて送信してみます。

# smtpy_sender.py

import os
import smtplib
from email.mime.text import MIMEText

from smtpapi import SMTPAPIHeader
from dotenv import load_dotenv
load_dotenv()


def main():
    server = create_smtp_server()

    from_email = os.environ['FROM_EMAIL']
    to_email = [
        os.environ['NOT_FOUND_EMAIL'],
        os.environ['EXISTS_EMAIL'],
    ]

    body = 'hello'
    message = MIMEText(body)
    message['From'] = from_email
    message['TO'] = ','.join(to_email)

    subject = 'メールバウンステスト'
    message['Subject'] = subject
    message['X-SMTPAPI'] = create_smtpapi_header(subject)

    server.sendmail(from_email, to_email, message.as_string())
    server.quit()


def create_smtp_server():
    host = 'smtp.sendgrid.net'
    port = 587
    user = 'apikey'
    password = os.environ['SENDGRID_API_KEY']
    server = smtplib.SMTP(host, port)
    server.starttls()
    server.login(user, password)
    return server


def create_smtpapi_header(subject):
    header = SMTPAPIHeader()
    header.set_unique_args({
        'foo': 'ふー',
        'subject': subject
    })
    return header.json_string()
    

if __name__ == '__main__':
    main()

 
なお、メール送信アプリで必要な情報は .env ファイルを用意して python-dotenv にて読み込むようにしました。
https://github.com/theskumar/python-dotenv

SENDGRID_API_KEY=
FROM_EMAIL=
NOT_FOUND_EMAIL=
EXISTS_EMAIL=

 

実行結果

上記のPythonスクリプトpython stmp_sender.py として実行した時の結果を確認します。

 

Googleスプレッドシート

Googleスプレッドシートにバウンスしたメールアドレスの情報が書き込まれました。

また、X-SMTPAPIヘッダに入れた項目も、 foo=ふー のように書き込まれていました。

そのため、 Unique Arguments を使うことで、バウンスしたときに日本語の識別情報を受け取れそうでした。

 

SendGridのコンソール

SendGridのコンソールにある SuppressionsBounceにも、通知のあったメールアドレスが書き込まれます。

 

受信メールのヘッダ

正常に受信したメールのヘッダを見てみましたが、X-SMTPAPI ヘッダはありませんでした。

SendGridから送信されるときに削除されるようです。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/sendgrid_event_webhook-sample