Vue.js、IMAP × PHPで学ぶメール取得アプリの基本構築法

Vue.js

リアルタイムで受信メールをチェックできるシンプルなメールビューアを、Vue 3 と PHP を使って構築してみましょう。このアプリでは、定期的にサーバーへ問い合わせる「ロングポーリング」という技術を使い、新着メールを自動で検出して一覧に反映します。

使用する技術・構成

項目内容
フロントエンドVue 3(CDN)
バックエンドPHP 8以上(IMAP拡張必須)
メール接続IMAP(SSL対応)
通信方式ロングポーリング(定期的にリクエストを送信)

環境構築と準備

まずは、このアプリを動かすための環境を整えましょう。ローカルでもサーバーでも構築可能です。

PHPが使えるサーバーを用意する

今回はローカル開発環境として XAMPP(Windows / macOS / Linux対応)を使用するのが簡単です。

  • XAMPP公式ダウンロードページ
  • インストール後、Apache を有効にしておきます
  • htdocs フォルダ内で .php ファイルを作成できる状態にしておきます

✅ IMAPを扱うには、PHPの IMAP拡張が有効 である必要があります。

XAMPPの場合は、php.ini に以下の記述があるか確認してください:

extension=imap
INI

変更後はApacheの再起動をお忘れなく。

IMAPが使えるメールアカウントを用意する

このアプリでは、IMAP接続に対応したメールアカウントが必要です。たとえば:

  • 独自ドメインのメール(レンタルサーバーや企業メール)
  • Gmail(2段階認証+アプリパスワードを使えばOK)

⚠ Gmailを使う場合。

  • IMAPアクセスを有効にする(Gmail設定内)
  • 通常のパスワードでは接続できないため、「アプリパスワード」を発行
  • 接続情報は次のようになります。
ホスト: imap.gmail.com
ポート: 993
暗号化: SSL
INI

全体構成と動作の概要

このアプリは、PHPとVueを組み合わせて、メールの取得・表示をリアルタイムに行うシンプルなWebアプリケーションです。以下のような流れで動作します。

  • メールをPHPで全件取得
    PHPのimap関数を使用して、指定アカウントの受信トレイからすべてのメールを取得します。本文はquoted-printable形式などを展開し、JSONとして扱えるように加工します。
  • JSONでVueに返却
    取得したメールデータをPHP側で整形し、Vue側に向けてjson_encode()で出力します。本文・件名・送信者・日時・メッセージ番号などを含んだ構造です。
  • Vueで一覧表示・選択・ページネーション
    フロントエンドではVue 3を使用し、取得したメールを一覧として表示。任意の1件をクリックすると詳細を確認できます。件数が多い場合に備えて、ページごとに表示件数を制御できるページネーションも組み込んでいます。
  • ロングポーリングで最新状態を維持
    一定間隔でPHPに再アクセスし、新しいメールが届いていれば即座に反映されます。WebSocketではなく、ロングポーリングで構成しているため、比較的シンプルに実装可能です。

PHPでIMAP接続&メールデータ取得

IMAP接続の初期化と認証

まず、imap_open()を使用してIMAPサーバへ接続します。SSLポート(993)を利用し、安全に通信を行います。

$hostname = '{imap.mail.jp:993/imap/ssl}INBOX';
$username = 'mail@mail.mail';
$password = 'your-password-here';

$inbox = imap_open($hostname, $username, $password);


if (!$inbox) {
    echo json_encode(['error' => imap_last_error()]);
    exit;
}
PHP

接続失敗時には、imap_last_error()でエラーメッセージを取得し、JSON形式で返却して処理を終了します。

メールの取得処理全体

接続後、imap_search()で全メールのメッセージ番号一覧を取得します。これをもとにimap_fetch_overview()で件名や送信者などの概要を、imap_fetchbody()で本文を取得します。

$emails = imap_search($inbox, 'ALL');
rsort($emails); // 新しい順に並び替え
PHP

各メールについて、次のような処理を行います。

foreach ($emails as $msgNum) {
    $overview = imap_fetch_overview($inbox, $msgNum, 0)[0] ?? null;

    $subject = isset($overview->subject) ? imap_utf8($overview->subject) : '(件名なし)';
    $sender  = isset($overview->from)    ? imap_utf8($overview->from)    : '(差出人不明)';
    $date    = $overview->date ?? '';
PHP
  • 件名・送信者は imap_utf8() で文字化け対策
  • 日付はそのまま抽出

quoted-printable の展開と htmlspecialchars によるサニタイズ

メール本文はエンコード形式としてquoted-printableが使われていることが多いため、quoted_printable_decode()で通常の文字列に展開します。その後、Vueで安全に扱えるように、htmlspecialchars()でエスケープ処理を施します。

$bodyRaw = imap_fetchbody($inbox, $msgNum, 1);
$decoded = quoted_printable_decode($bodyRaw);
$sanitizedBody = htmlspecialchars($decoded, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$body = imap_utf8($sanitizedBody);
PHP

これにより、HTMLタグなどがそのまま表示されるリスクを防ぎます。

すべての情報を配列にまとめ、次の形式で保存します。

$mail_data[] = [
    'subject' => $subject,  // 件名(imap_utf8で文字化け防止済み、未設定なら「(件名なし)」)
    'sender'  => $sender,   // 差出人(imap_utf8でデコード、未設定なら「(差出人不明)」)
    'date'    => $date,     // 受信日時(形式はメールヘッダそのまま)
    'body'    => $body,     // 本文(quoted-printable展開+htmlspecialcharsで安全化済み)
    'msgnum'  => $msgNum    // メッセージ番号(IMAP操作時に利用する一意の番号)
];
PHP

JSONエンコード&出力(JSON_UNESCAPED_UNICODE)

整形したメールデータは、JSON形式でVueに返します。日本語などの文字化けを防ぐため、JSON_UNESCAPED_UNICODE オプションを指定しています。

echo json_encode([
    'status' => '✅ 取得成功',
    'count' => count($emails),
    'mail' => $mail_data
], JSON_UNESCAPED_UNICODE);
PHP

Vueアプリの基本構造

このアプリでは、Vue 3(Composition API)をCDN経由で導入し、コンポーネントは使わずに1ファイル内で構成しています。初学者にも扱いやすいシンプルな構成です。

Vueの初期セットアップ(CDN使用)

VueはCDNから読み込み、#app の中に直接マウントする構成です。複雑なビルドツールは不要で、HTML1枚から動作します。

<script src="https://unpkg.com/vue@3.4.30/dist/vue.global.js"></script>
HTML
const { createApp } = Vue;

createApp({
  // ...
}).mount('#app');
JavaScript

データ構造

Vueアプリ内部では以下のような状態を管理します。

data() {
  return {
    mails: [],               // 受信した全メールを格納
    selectedMailIndex: null, // 選択中のメールインデックス
    currentPage: 1,          // 現在のページ番号
    perPage: 10              // 1ページあたりの表示件数
  };
}
JavaScript

コンポーネントなしのシンプル構成での実装

このアプリはVue単体で構築されており、Single File Component(SFC)などは使用しません。1ページで完結しているため、HTML/CSS/JSをそれぞれ同じファイル内に記述しています。

メール一覧と詳細表示

v-for による一覧描画

受信したメールは v-for を使って一覧表示されます。各アイテムはクリック可能で、選択されたものはハイライトされるようになっています。

<div 
  v-for="(mail, index) in paginatedMails" 
  :key="mail.uid"
  :class="['mail-item', { active: selectedMailIndex === index }]"
  @click="selectMail(index)"
>
  <strong>{{ mail.subject }}</strong><br>
  {{ mail.body.substring(0, 30) }}...
</div>
HTML

selectedMail の計算プロパティ

現在選択中のメールを取得するための計算プロパティです。選択されていない場合は null を返します。

computed: {
  selectedMail() {
    return this.selectedMailIndex !== null ? this.paginatedMails[this.selectedMailIndex] : null;
  }
}
JavaScript

クリックで選択されたメールを表示する処理

一覧内のアイテムをクリックすると、対応するメールが詳細エリアに表示されます。

methods: {
  selectMail(index) {
    this.selectedMailIndex = index;
  }
}
JavaScript

詳細ビューでは、選択された selectedMail の内容を元に、件名・差出人・受信日時・本文を表示します。

ページネーションの実装

表示するメールが多くなると、1画面では全てを確認できません。そこで、1ページあたり10件ずつ表示するシンプルなページネーションを導入しています。

paginatedMails 計算プロパティ

現在のページに表示するメール一覧を算出するための計算プロパティです。currentPageperPage をもとに、一覧の一部だけを切り出して表示します。

computed: {
  paginatedMails() {
    const start = (this.currentPage - 1) * this.perPage;
    return this.mails.slice(start, start + this.perPage);
  }
}
JavaScript

currentPage, pageCount の仕組みと切り替えボタン

ページ番号の状態は currentPage に保持され、総ページ数は pageCount として計算します。

computed: {
  pageCount() {
    return Math.ceil(this.mails.length / this.perPage);
  }
}
JavaScript

表示切り替えは「前へ」「次へ」ボタンで制御しています。現在ページの境界に達するとボタンが無効化されます。

<button @click="prevPage" :disabled="currentPage === 1">前へ</button>
<span>ページ {{ currentPage }} / {{ pageCount }}</span>
<button @click="nextPage" :disabled="currentPage === pageCount">次へ</button>
HTML

1ページに表示する件数は perPage: 10 として定義しており、変更も容易です。paginatedMails のスライス処理に反映されているため、柔軟に対応できます。

ロングポーリングで定期取得

新着メールを自動で反映させるため、ロングポーリングを使った定期取得処理を組み込んでいます。

loadMails() の実装

fetch() を使ってPHPからJSONを取得し、mails 配列に格納します。取得完了後、再度 loadMails() を呼び出すことで、次の取得を待機します。

loadMails() {
  fetch('main10.php')
    .then(res => res.text())
    .then(txt => {
      let data;
      try {
        data = JSON.parse(txt);
      } catch (e) {
        // JSONエラー処理(下記参照)
        this.mails = [];
        setTimeout(this.loadMails, 5000);
        return;
      }

      if (Array.isArray(data.mail)) {
        this.mails = data.mail;
      } else {
        this.mails = [];
      }

      this.loadMails(); // 次回呼び出し
    })
JavaScript

通信エラー時の自動リトライ

ネットワークエラーなどで取得に失敗した場合は、catch ブロックでエラーを受け取り、5秒後に再試行する設計です。

.catch(err => {
  console.error("❌ 通信エラー:", err);
  setTimeout(this.loadMails, 5000);
});
JavaScript

JSONパース失敗時のエラー処理と継続制御

PHPのレスポンスが不正なJSONだった場合(例えばエラーメッセージが出力された場合)、例外を補足し、強制終了せずリトライする構成です。

try {
  data = JSON.parse(txt);
} catch (e) {
  console.error("❌ JSON変換エラー:", e);
  this.mails = [];
  setTimeout(this.loadMails, 5000);
  return;
}
JavaScript

これで、ページ送りと同時に、リアルタイムで新着メールが反映されるシンプルかつ実用的なメールビューアが完成します。

完成系のコード

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>メール一覧</title>
  <script src="https://unpkg.com/vue@3.4.30/dist/vue.global.js"></script>
  <style>
    body { font-family: Arial, sans-serif; margin: 0; background-color: #f5f5f5; }
    header { background-color: #3a3a3a; color: white; padding: 10px; text-align: center; }
    .container { display: flex; height: calc(100vh - 40px); }
    .mail-list { width: 30%; background-color: white; border-right: 1px solid #ccc; overflow-y: auto; }
    .mail-item { padding: 15px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s; }
    .mail-item:hover { background-color: #f0f0f0; }
    .mail-item.active { background-color: #e0e0e0; }
    .mail-subject { font-weight: bold; margin-bottom: 5px; }
    .mail-snippet { color: #666; }
    .mail-view { flex: 1; background-color: white; padding: 20px; overflow-y: auto; }
    .mail-header { margin-bottom: 20px; border-bottom: 1px solid #ccc; padding-bottom: 10px; }
    .mail-header h2 { margin: 0; }
    .mail-header p { margin: 5px 0; color: #555; }
    .mail-body { white-space: pre-wrap; }
  </style>
</head>
<body>
  <header><h1>メールアプリ</h1></header>
  <div id="app" class="container">
    <div class="mail-list">

      <div 
        v-for="(mail, index) in paginatedMails" 
        :key="mail.uid"
        :class="['mail-item', { active: selectedMailIndex === index }]"
        @click="selectMail(index)"
      >
        <strong>{{ mail.subject }}</strong><br>
        {{ mail.body.substring(0, 30) }}...
      </div>

    </div>

    <div style="padding: 10px; text-align: center;">
      <button @click="prevPage" :disabled="currentPage === 1">前へ</button>
      <span>ページ {{ currentPage }} / {{ pageCount }}</span>
      <button @click="nextPage" :disabled="currentPage === pageCount">次へ</button>
    </div>

    <div class="mail-view" v-if="selectedMail">
      <div class="mail-header">
        <h2>{{ selectedMail.subject }}</h2>
        <p><strong>差出人:</strong> {{ selectedMail.sender }}</p>
        <p><strong>受信日時:</strong> {{ selectedMail.date }}</p>
      </div>
      <div class="mail-body">{{ selectedMail.body }}</div>
    </div>

    <div class="mail-view" v-else>
      <p>メールを選択してください。</p>
    </div>

  </div>

  <script>
    const { createApp } = Vue;

    createApp({
      data() {
        return {
          mails: [],
          selectedMailIndex: null,

          // ★ ページネーション用データ
          currentPage: 1,
          perPage: 10
        };
      },
      computed: {
        selectedMail() {
          return this.selectedMailIndex !== null ? this.paginatedMails[this.selectedMailIndex] : null;
        },
        // ★ 現在のページに表示するメール
        paginatedMails() {
          const start = (this.currentPage - 1) * this.perPage;
          return this.mails.slice(start, start + this.perPage);
        },
        // ★ 総ページ数
        pageCount() {
          return Math.ceil(this.mails.length / this.perPage);
        }
      },
      methods: {
        selectMail(index) {
          this.selectedMailIndex = index;
        },

        // ★ ページ切り替え
        nextPage() {
          if (this.currentPage < this.pageCount) {
            this.currentPage++;
            this.selectedMailIndex = null;
          }
        },
        prevPage() {
          if (this.currentPage > 1) {
            this.currentPage--;
            this.selectedMailIndex = null;
          }
        },

        // 🔁 ロングポーリングでメール取得
        loadMails() {
          fetch('main10.php')
          .then(res => res.text())
          .then(txt => {
            console.log("📦 生レスポンス");

            let data;
            try {
              data = JSON.parse(txt);
            } catch (e) {
              console.error("❌ JSON変換エラー(スキップ):", e);
              console.log("一部のメール取得に失敗しましたが、アプリは続行します。");
              this.mails = [];
              setTimeout(this.loadMails, 5000); // JSON失敗時も次回リトライ
              return;
            }

            if (data.status) {
              console.log("✅ ステップ成功:", data.status);
              console.log("📥 データ内容:", data);
            }

            if (Array.isArray(data.mail)) {
              this.mails = data.mail;
            } else {
              console.warn("⚠️ メールデータが見つかりません");
              this.mails = [];
            }

            // 次のリクエスト(成功時)→ ロングポーリング再開
            this.loadMails();
          })
          .catch(err => {
            console.error("❌ 通信エラー:", err);
            setTimeout(this.loadMails, 5000); // 通信失敗時は5秒後に再実行
          });
        }
    },
    mounted() {
      this.loadMails(); // 初回起動&ロングポーリング開始
    },
    }).mount('#app');
  </script>
</body>
</html>
HTML
<?php
$hostname = '{imap.mail.jp:993/imap/ssl}INBOX';
$username = 'mail@mail.mail';
$password = 'your-password-here';

$inbox = imap_open($hostname, $username, $password);

if (!$inbox) {
    echo json_encode(['error' => imap_last_error()]);
    exit;
}

$emails = imap_search($inbox, 'ALL');

if (!$emails || !is_array($emails)) {
    imap_close($inbox);
    echo json_encode([]);
    exit;
}

rsort($emails); // 最新順
$mail_data = [];
if ($emails) {
    foreach ($emails as $msgNum) {
        $overview = imap_fetch_overview($inbox, $msgNum, 0)[0] ?? null;

        $subject = isset($overview->subject) ? imap_utf8($overview->subject) : '(件名なし)';
        $sender = isset($overview->from) ? imap_utf8($overview->from) : '(差出人不明)';
        $date  =  $overview->date ?? '';

        // HTMLが含まれていても、JSONとして安全にする(必要に応じて)
        $bodyRaw = imap_fetchbody($inbox, $msgNum, 1);
        $decoded = quoted_printable_decode($bodyRaw); // ← 特殊文字展開
        $sanitizedBody = htmlspecialchars($decoded, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); // ← JSONとして安全化
        $body = imap_utf8($sanitizedBody);

        $mail_data[] = [
            'subject' => $subject,
            'sender'  => $sender,
            'date'    => $date,
            'body'    => $body,
            'msgnum' => $msgNum
        ];
    }
}
imap_close($inbox);
echo json_encode([
    'status' => '✅ 1通取得成功',
    'count' => is_array($emails) ? count($emails) : 0,
    'mail' => $mail_data,
    'emails' => $emails,
], JSON_UNESCAPED_UNICODE);
JavaScript

もちろんです。以下は、全体をコンパクトに再構成した「セキュリティ上の懸念点」セクションです。記事の末尾にそのまま掲載できるように、簡潔かつ自然なトーンに整えています。


🔒 セキュリティ上の懸念点と対策

このアプリはシンプルな構成ですが、実運用を想定する場合には以下のセキュリティ対策も検討が必要です。

IMAP認証情報のハードコーディング

ソースコード内にメールアカウントとパスワードを直接記述しているため、第三者に漏洩するリスクがあります。
対策.envファイルや設定ファイルに分離し、getenv()などで読み込む形式に変更しましょう。

APIが誰でもアクセス可能

現在の main10.php は認証なしで公開されており、誰でもメール情報にアクセスできる状態です。
対策:セッション認証やAPIトークンの導入により、認可されたユーザーのみに制限する必要があります。

HTMLメール対応時のXSS対策

今はテキストメールのみですが、今後HTML本文を表示する場合、スクリプトなどが実行される可能性があります。
対策:DOMPurify 等を用いてHTMLを安全にフィルタリングする、またはiframeによる分離表示を検討しましょう。

エラーメッセージの情報漏洩

imap_last_error() の内容がそのままクライアントに返されるため、内部構造やホスト情報が漏れる可能性があります。
対策:本番環境では詳細なエラーはログにのみ記録し、レスポンスには汎用的なメッセージを返すようにします。

ロングポーリングの乱用リスク

クライアント側は無制限に loadMails() を繰り返すため、アクセスが集中するとサーバに負荷がかかります。
対策:クールタイムを設ける、サーバ側でIPごとのリクエスト数を制限するなどの制御が有効です。

🔧 実際の商用構成の一例

今回のように、IMAP + PHP + Vue を使ったメールビューアは、学習や個人開発には非常に有効な構成です。ただし、大手のメールサービスでは、さらに拡張性や安全性を重視した仕組みが使われています。

  • メール送受信には、SMTP(送信)と IMAP/POP3(受信)が基本
  • 配信には Postfix、Amazon SES、SendGrid などのMTAを活用
  • 受信メールは IMAPで取得後、サーバ側でDBに保存
  • フロントエンドは IMAPではなくAPI(REST)でメールを取得
  • 検索には Elasticsearch などを併用するケースも
  • 認証は OAuth2.0 や APIトークン による制御が主流
  • スパム対策には SPF/DKIM/DMARC を導入し、なりすまし防止

✔ ポイント

商用環境では「メールをAPIで扱いやすく変換 → DBに保存 → クライアントからREST経由で取得」が主な流れです。

まとめと今後の拡張

本記事では、PHP(IMAP)とVue 3を組み合わせて、Web上でメールを閲覧できる簡易ビューワーを構築しました。

✅ 実装できたこと

  • IMAPを使って受信メールをPHPで全件取得
  • quoted-printable形式の展開と安全なエスケープ処理
  • JSON形式でVue側へデータ転送
  • Vueによる一覧・詳細表示の構築
  • ページネーションによる多件対応
  • ロングポーリングで最新メールを自動取得

これにより、サーバ上のメールをリアルタイムでチェックできるシンプルかつ機能的なインターフェースが実現しました。


🔧 今後の拡張ポイント

現在の機能をベースに、以下のような機能追加が考えられます。

  • 添付ファイルの表示・ダウンロード対応
    imap_fetchstructure() などを使って添付ファイルの処理を追加
  • HTMLメールの対応(本文のリッチ表示)
    imap_fetchbody() でパート番号2以降も含め、HTML本文を抽出+sanitize処理
  • メール検索機能の追加
    件名や送信者でのフィルタリング機能をVue側に実装
  • 新着通知バッジ・音通知の追加
    ロングポーリングに新着判定処理を加え、通知制御を導入
  • ログイン認証・マルチユーザー対応
    セキュリティ面を強化し、複数ユーザー環境でも使用可能にする

シンプルながらも柔軟に拡張できる構成なので、必要な機能を段階的に追加しながら、用途に合ったメールビューアへと進化させていくことが可能です。

コメント

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