アプリの問い合わせ先をSlackにしてメールサーバーを浮かせちゃおう!前編

JavaScript

WEBサイトやアプリケーションを構築していると、必ずと言っていいほど「お問い合わせフォーム」なるものがございます。
ユーザーが入力した内容を、運用している自社(自分の)メールサーバーに飛ばす…っていう、超オーソドックスなあれです。

ここ最近リリースしたWEBサイトにも、例のごとく「お問い合わせフォーム」は実装しているのですが。

メールサーバー、契約したくなかったんですよね…ケチだから…。

何か慣れ親しんだツールで、かつ、無料で問い合わせ内容をキャッチできないだろうか?
ついでに、業務でも活用できる方法がいいな、ということで。

我がWEBサイトのフォームは、全部Slackが受け止めてくれているのです。

全体の構成

フォームの内容をSlackに流し込む「受け皿」を作ることにしたわけですが、簡単に構成を紹介します。

各サイトのお問い合わせフォーム
        │  fetch で POST
        ▼
自分で立てた APIサーバー(Node.js + Express)
        │  Slack Webhook へ転送
        ▼
Slack のチャンネルに通知が届く

ポイントは、フォームが直接Slackを叩くのではなく、あいだにAPIサーバーを一枚かませているってところです。

なぜAPIサーバーを挟むのか?

URLの秘匿

Slackに送るためには「Webhook URL」という、いわば鍵のようなものが必要になります。
これをフロント側(=ブラウザで誰でも見られる場所)に書いてしまうと、URLを盗まれて悪用され兼ねません。
そこで、URLはサーバー側に隠しておき、フォームからはそのサーバーにだけ送信する―――この形に落ち着きました。

問い合わせの一元管理

そしてこの構成の何がいいって、複数サイトの問い合わせを一つのアプリでまとめて受けられるってところなんですよね。
Slackならチャンネルで切り分けることもできるし、通知のオン・オフもしやすい。
結局Slackが有能ってだけの話なんだよな…。

メールサーバーで代用する場合

実際、これをメールでやろうとすると地味~に手間なんです。

送信ソフト(Postfix等)を入れて設定し、VPSで塞がれがちな25番ポートの解除を申請し、さらに迷惑メール対策(SPF / DKIM / DMARC)まで整える……。
こういった一連の作業が必要で、「借りれば勝手に使える」類のものではありません。
その煩わしさをまるっと飛ばせるのが、Slackを採用するいちばんのうまみでしたね。

事前準備

さて、構築に際して私が準備しておいたアプリケーションやインフラまわりはこちらです。

  • WEBアプリ
    今回は gradient-generator を例にしています。
    Vite で作ったフロントエンドのみのサービス(JavaScript)で、ConoHaVPS上の nginx が配信しています。
  • APIサーバー(今回作る受け皿)
    • Node.js
      JavaScriptをサーバー側で動かすための土台。まずこれをインストールします。
    • Express
      Node.jsの上でWebサーバーを簡単に書きたい!というときの定番ライブラリ。
    • dotenv
      .env ファイルから値(Webhook URL など)を読み込むためのライブラリ。
  • Slack の Incoming Webhook
    指定したチャンネルにメッセージを投稿するための「専用URL」です。
    このURLに向けてJSONをPOSTするだけで、Slackに通知が飛びます。

Slack側でWebhook URLを用意する

Webhook URLの発行については、以下の記事でわかりやすく解説しております!
メッセージを飛ばしたいチャンネルを作ったら、こちらの手順でURLを取得しましょう。

【最新版】Slack Appの作り方とWebhook URLの発行方法【2026】
Slack AppのIncoming Webhookを画像付きで解説。APIの管理画面でのアプリ作成からWebhook URLの発行まで、公式ヘルプよりわかりやすくステップごとに紹介します。旧方式との違いや新方式での注意点も合わせて確認できます。

ディレクトリ・設定ファイルの作成

作業用のフォルダを作って、必要なものを入れます。
今回はapi-serverというディレクトリで作業しました。

mkdir api-server
cd api-server
npm init -y
npm install express dotenv

package.json を用意する

npm init -y を実行することで、プロジェクトの「設計図」にあたる package.json が自動で作成されます。
また、後続の npm install express dotenv で、ひな型となる package.json に dependencies の欄が追加されるのです。

ここまではコマンド実行で勝手に書き込んでくれるのですが、以降は自分で編集しておきましょう。

  • 1つめは "type": "module" の追加。これで import express from 'express' という書き方(ESモジュール)が使えるようになります。
  • 2つめは "scripts" に起動コマンドを登録すること。npm start と打つだけでサーバーが立ち上がるようにします。

最終的に、package.json はこうなりました。

{
"name": "api-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"dotenv": "^16.0.0",
"express": "^4.18.0"
}
}

ポイントだけ補足しておきます。

  • "type": "module" … import 構文を使うための宣言。これが無いと import はエラーになります。
  • "scripts" の start … npm start で node server.js が走るショートカット。ここに dev は登録していないので、npm run dev を打つとエラーになります(※大伏線)。
  • "private": true … npmに間違って公開しないための安全装置。自分用サーバーなので付けています。
  • ^(キャレット) … ^4.18.0 は「4系の範囲で最新はOK、でも5.0.0にはしない」という意味。勝手に大型アップデートされて壊れるのを防ぎます。

.env に隠したい値をまとめる

APIサーバーが使う値は、.env に隠しておきましょう。
先ほど発行したWebhook URLもここに書きます。

SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXX/YYYY/ZZZZ
PORT=3001

.env ファイルというと数十行あるイメージをしてしまいますが、今回は2行でOK。
server.js の冒頭にある import 'dotenv/config' が、この .env を読み込んでくれます。

PORTを指定している理由ですが、正直、server.js で process.env.PORT || 3001 としているので、今思えば書く必要はなかったのかな~と思いました。
まぁいつか突然ポートを変えたい日が来るかもしれませんしね、そのハンドリングのためとこじつけて次に行きましょう。

前編のまとめ

これでSlack側の準備と、開発環境の用意ができました。
後編ではいよいよ、APIサーバー本体 server.js を構築していきます。

「今すぐ教えろ!」って方向けに、先んじて全体像を載せておきますので、コピペ&適宜変更してご自由に使ってくださいませ。
ブロックごとに何をしているのかは、後編で詳しく解説いたします。

import 'dotenv/config'
import express from 'express'

const app  = express()
const PORT = process.env.PORT || 3001

app.use(express.json())

const ALLOWED_ORIGINS = [
  'https://gradient-generator.hibitsuiki.com',
  'http://localhost:5173',
  'https://extension-quiz.hibitsuiki.com',
  'http://localhost:8080',
]

app.use((req, res, next) => {
  const origin = req.headers.origin
  if (ALLOWED_ORIGINS.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin)
  }
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
  res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
  if (req.method === 'OPTIONS') return res.sendStatus(204)
  next()
})

async function notifyError(text) {
  try {
    await fetch(process.env.SLACK_WEBHOOK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        blocks: [{
          type: 'section',
          text: { type: 'mrkdwn', text: `🚨 *APIサーバーエラー*\n\`\`\`${text}\`\`\`` },
        }],
      }),
    })
  } catch { /* Slack自体が落ちている場合は無視 */ }
}

process.on('uncaughtException',  async (err)    => { await notifyError(`uncaughtException: ${err.message}`); process.exit(1) })
process.on('unhandledRejection', async (reason) => { await notifyError(`unhandledRejection: ${reason}`) })

app.post('/form', async (req, res) => {
  const { source, name, email, type, message } = req.body
  if (!message) return res.status(400).json({ error: 'message is required' })

  const blocks = [
    {
      type: 'section',
      text: { type: 'mrkdwn', text: `📬 *【お問い合わせ】${source ?? ''}*` },
    },
    { type: 'divider' },
    {
      type: 'section',
      text: {
        type: 'mrkdwn',
        text: [
          `📂 カテゴリ:${type || '(未選択)'}`,
          `👤 お名前:${name ?? '(未入力)'}`,
          `✉️ メールアドレス:${email ?? '(未入力)'}`,
        ].join('\n'),
      },
    },
    {
      type: 'section',
      text: { type: 'mrkdwn', text: `🗨️ お問い合わせ内容\n${message}` },
    },
  ]

  try {
    const slackRes = await fetch(process.env.SLACK_WEBHOOK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ blocks }),
    })

    if (!slackRes.ok) {
      const errText = await slackRes.text()
      console.error('Slack error:', errText)
      return res.status(502).json({ error: 'slack error' })
    }

    res.json({ ok: true })
  } catch (err) {
    console.error(err)
    res.status(500).json({ error: 'internal error' })
  }
})

app.listen(PORT, () => {
  console.log(`API server running on port ${PORT}`)
})

ではまた次回!

タイトルとURLをコピーしました