EY-Office ブログ

PythonでもJSXが使えるぞ! PyJSXを使ってHTMLメールを作ってみた

先週のブログGmailのPOPサポート終了に対応する為のツールをAIに作ってもらったの続きです。
先週のブログで書いたように、昨日に受信したメールの一覧のメールを送信するツールをPythonで作りましたが不満があり通常のテキストメールではなくHTMLメールを送りたいと思いました。

PyJSX Nano Banana Proが生成した画像を使っています

テキストメールは読みにくいね

先週作ったツールから送られてくるメールは以下のようになっています(画像はぼかしています)。メールは時間 送信者 件名の一覧(表)で、縦方向もキレイに並ぶように少しだけ努力していますが、正確には揃っていません。
また、送信者や件名は短く切っていますがスクロールして後半を見たりはできません。

そんな要望にはHTMLメールですね。以下の画像は今回作ったツールの送信してくるHTMLメールです。テーブルで縦方向も揃っていますし、送信者や件名はスクロールして後半も見れます。

PyJSX

さてHTMLを生成するにはテンプレートエンジンを使う事が多いと思います、Pythonでは以前取り上げた有名フレームワークDjyangoDTL(Django Template Language)Jinja(Jinja2)が良く使われているようです。
しかし、最近はReactばかり書いてきた私はJSXが使いたいなぁ〜と思って検索したところPyJSXを発見しました❗

PyJSXの簡単な解説

PyJSXはPythonの中にHTMLを書けるJSX同様のライブラリーです。PyJSXのGitHubにあるExampleを使って簡単に解説しますね。

main.py

main.pyはJSXを使ったコードを呼出すコードです。

  • ① このインポートを行うと以降でimportされるモジュール読み込み時にJSXをPythonに変換する処理が組み込まれます
import pyjsx.auto_setup             # ← ①

from hello import hello

hello()
hello.py

hello.pyはJSXが書かれているPythonモジュールです。

  • ② 先頭の# coding: jsxはPythonのコーディング指定機能で通常は文字コードを指定しますが、ここではJSXライブラリーのTokenizerが動作しJSX文法を解釈し、さらにTranspilerが動作しJSXがPythonコードに変換されます
  • ③ ReactのJSX同様にPythonコード内に直接HTMLが書けます
  • ④ ReactのJSX同様に{}内にはPythonの式が書けます
# coding: jsx                        # ← ②
from pyjsx import jsx

def hello():
    to = "world"
    print(<h1>Hello, {to}!</h1>)    # ← ③、④

したがって、繰り返しは以下のようにリスト内包表記を使って書けます。

<div>
    {[<p>Row: {i}</p> for i in range(3)]}
</div>

 ↓ 結果

<div>
    <p>Row: 0</p> <p>Row: 1</p> <p>Row: 2</p>
</div>

今回のコード

先週のコードは色々な部分を改良したので、軽く解説します。

メイン yesterday_mails.py

  • ① このインポートは重要です
#!/usr/bin/env python3

import pyjsx.auto_setup                                     # ← ①
from datetime import date, timedelta
from mail_utils import fetch_latest_emails, send_html_email
from jsx_render import make_html_mail

READ_MAIL_URL = "https://my-server.com/read_mail"

def main():
    # 昨日のメール一覧を取得
    yesterday = (date.today() - timedelta(days=1))
    yesterday_mails = fetch_latest_emails(date=yesterday, num_emails=100)
    yesterday_str = yesterday.isoformat()

    # HTMLメールを生成
    html = make_html_mail(yesterday, yesterday_mails, READ_MAIL_URL)
    # print(html)

    # メール一覧をメール送信
    send_html_email(f"メール一覧 {yesterday_str}", html)

if __name__ == "__main__":
    main()

HTMLメール作成 jsx_render.py

  • ① このコーディング指定は重要です!
  • ② 今回はスタイル情報はstyle属性で指定しています
    • ここはReactと違い通常のCSSと同じ小文字・ハイフンで指定します
  • ③ テーブルの幅指定には表の列グループ要素を使っています。初めて使いました😅
  • ④ 将来は日付をクリックするとメール内容が読めるようにリンクを追加しました。まだサーバー側は実装されていません
  • ⑤ リスト内包表記を使いテーブルの行を表示しています
  • ⑥ JSX内にはスタイル情報<style>を書けないので、文字列に変換した後でスタイル情報を埋め込んでいます
  • ⑦ JSXオブジェクトをHTML文字列に変換しています

⑥のスタイル指定ですが、tr:hoverはこのメールを確認する際のユーザビリティを格段に上げます。これは絶対に入れたかったのですがstyle属性では指定出来ないのでのでこのような形で実装しました。

# coding: jsx                                            # ← ①
from pyjsx import jsx, JSX

def make_table(emails, read_mail_url):
    table_style = {                                      # ← ②
        "width": "100%",
        "border-collapse": "collapse",
        "border": "1px solid #e0e0e0",
        "table-layout": "fixed"
    }
    th_style = {
        "padding": "6px 12px",
        "text-align": "left",
        "background-color": "#e0e0e0",
        "border-bottom": "1px solid #e0e0e0"
    }
    td_style = {
        "padding": "6px 12px",
        "border-bottom": "1px solid #e0e0e0",
        "color": "#555",
        "overflow-x": "scroll",
        "white-space": "nowrap"
    }
    a_style = {
        "color": "#555",
        "text-decoration": "none"

    }
    return (
        <table style={table_style}>
            <colgroup>                                 { # ← ③ }
                <col style={{"width": "10%"}} />
                <col style={{"width": "30%"}} />
                <col style={{"width": "55%"}} />
            </colgroup>
            <thead>
                <tr>
                    <th style={th_style}>時刻</th>
                    <th style={th_style}>送信者</th>
                    <th style={th_style}>件名</th>
                </tr>
            </thead>
            <tbody>
                {
                    <tr>
                        <td style={td_style}>
                            <a href={f"{read_mail_url}/{email['uid']}"} style={a_style}>
                                {email['date'][11:]}       # ← ④
                            </a>
                        </td>
                        <td style={td_style}>{email['from']}</td>
                        <td style={td_style}>{email['subject']}</td>
                    </tr>
                    for email in emails                    # ← ⑤
                }
            </tbody>
        </table>
    )

def make_html_mail(date_str, emails, read_mail_url):
    html = (
        <html lang="ja">
            <head>
                <meta charset="UTF-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1.0" />
                <title>メール一覧</title>
                STYLE                                   { # ← ⑥ }
            </head>
            <body style={{"margin": "20px"}}>
                <h1>Date:  {date_str}</h1>
                {make_table(emails, read_mail_url)}
            </body>
        </html>
    )
    # JSXを文字列に変換
    html_str = str(html)                                 # ← ⑦

    # <style>を差し込む                                    # ← ⑥
    html_with_style = html_str.replace("STYLE", """
        <style>
            tr:hover {background-color: #f8f9fa;}
        </style>
    """)
    return html_with_style

メール受信、送信 mail_util.py

Pythonのimaplibは機能が豊富なわりに情報が少なく苦労しました。

  • ① imaplibでは取得するメールの条件が指定できます、ここではON 23-Dec-2025で昨日の日付を指定してメールを取得しています
    • 先週のコードでは最新の100件のメールを取得しプログラムで日付を比較して取得していましたね
  • ② 将来の機能拡張に備え、メールのユーニークIDを取得するようにしました
import imaplib
import smtplib
import email
import os
from datetime import timedelta, timezone
from email.header import decode_header
from email.utils import parsedate_to_datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart


# IMAP/SMTP接続設定
IMAP_SERVER = os.getenv("IMAP_SERVER")
SMTP_SERVER = os.getenv("SMTP_SERVER")
IMAP_PORT = int(os.getenv("IMAP_PORT"))
SMTP_PORT = int(os.getenv("SMTP_PORT"))
EMAIL_ACCOUNT = os.getenv("EMAIL_ACCOUNT")
EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD")


def connect_to_imap():
    """IMAPサーバーに接続"""
    mail = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT)
    mail.login(EMAIL_ACCOUNT, EMAIL_PASSWORD)
    return mail

def decode_mime_words(s):
    """MIMEエンコードされた文字列をデコード"""
    if s is None:
        return ''
    decoded_fragments = decode_header(s)
    fragments = []
    for fragment, encoding in decoded_fragments:
        if isinstance(fragment, bytes):
            # エンコーディングを明示的に処理
            if encoding:
                try:
                    fragment = fragment.decode(encoding, errors='replace')
                except:
                    fragment = fragment.decode('utf-8', errors='replace')
            else:
                # エンコーディングが不明な場合、複数試行
                for enc in ['iso-2022-jp', 'utf-8', 'shift_jis', 'euc-jp']:
                    try:
                        fragment = fragment.decode(enc)
                        break
                    except:
                        continue
                else:
                    fragment = fragment.decode('utf-8', errors='replace')
        fragments.append(str(fragment))
    return ''.join(fragments)

def convert_to_jst(date_str):
    """メールの日付を日本時間(JST)に変換"""
    try:
        # メールの日付文字列をdatetimeオブジェクトに変換
        dt = parsedate_to_datetime(date_str)

        # 日本時間(JST = UTC+9)に変換
        jst = timezone(timedelta(hours=9))
        dt_jst = dt.astimezone(jst)

        # yyyy-mm-dd HH:MM:SS 形式で返す
        return dt_jst.strftime('%Y-%m-%d %H:%M:%S')
    except:
        return date_str  # 変換できない場合は元の文字列を返す

def fetch_latest_emails(mailbox='INBOX', date=None, num_emails=10):
    """
    最新のメールを取得

    Args:
        mailbox: メールボックス名
        date: 取得するメールの日付(datetime.dateオブジェクト)。Noneの場合はすべてのメールを対象
        num_emails: 取得する最新メールの数
    Returns:
        メールのリスト(各メールは辞書形式)
    """

    try:
        # IMAPに接続
        mail = connect_to_imap()

        # メールボックスを選択
        mail.select(mailbox)

        # すべてのメールIDを検索                                       # ↓ ①
        criterion = date and f'ON {date.strftime("%d-%b-%Y")}' or 'ALL'
        status, messages = mail.uid('search', None, criterion)     # ← ②
        uids = messages[0].split()
        # 最新のメールIDを取得(昇順で取得)
        latest_uids = uids[-num_emails:]  # [::-1]

        emails = []

        for uid in latest_uids:
            # メールを取得
            status, msg_data = mail.uid('fetch',  uid, '(RFC822)')

            for response_part in msg_data:
                if isinstance(response_part, tuple):
                    # メールメッセージをパース
                    msg = email.message_from_bytes(response_part[1])

                    # 件名を取得
                    subject = decode_mime_words(msg['Subject']) if msg['Subject'] else 'No Subject'

                    # 送信者を取得
                    from_ = decode_mime_words(msg['From']) if msg['From'] else 'Unknown'

                    # 日付を取得して日本時間に変換
                    date = convert_to_jst(msg['Date']) if msg['Date'] else 'Unknown'

                    # 本文を取得
                    body = ""
                    if msg.is_multipart():
                        for part in msg.walk():
                            content_type = part.get_content_type()
                            content_disposition = str(part.get("Content-Disposition"))

                            if content_type == "text/plain" and "attachment" not in content_disposition:
                                payload = part.get_payload(decode=True)
                                if payload:
                                    # 文字エンコーディングを取得
                                    charset = part.get_content_charset()
                                    if charset:
                                        try:
                                            body = payload.decode(charset, errors='replace')
                                        except:
                                            body = payload.decode('utf-8', errors='replace')
                                    else:
                                        # charsetが指定されていない場合、複数試行
                                        for enc in ['iso-2022-jp', 'utf-8', 'shift_jis', 'euc-jp']:
                                            try:
                                                body = payload.decode(enc)
                                                break
                                            except:
                                                continue
                                        else:
                                            body = payload.decode('utf-8', errors='replace')
                                break
                    else:
                        payload = msg.get_payload(decode=True)
                        if payload:
                            charset = msg.get_content_charset()
                            if charset:
                                try:
                                    body = payload.decode(charset, errors='replace')
                                except:
                                    body = payload.decode('utf-8', errors='replace')
                            else:
                                # charsetが指定されていない場合、複数試行
                                for enc in ['iso-2022-jp', 'utf-8', 'shift_jis', 'euc-jp']:
                                    try:
                                        body = payload.decode(enc)
                                        break
                                    except:
                                        continue
                                else:
                                    body = payload.decode('utf-8', errors='replace')

                    emails.append({
                        'uid': uid.decode(),
                        'subject': subject,
                        'from': from_,
                        'date': date,
                        'body': body[:200]  # 本文の最初の200文字のみ
                    })
        print(f"-- Get email: {len(emails)}")
        return emails

    except imaplib.IMAP4.error as e:
        print(f"IMAP   Error : {e}")
        return []
    except Exception as e:
        print(f"Error : {e}")
        return []
    finally:
        mail.close()        # IMAPに接続を閉じる
        mail.logout()

def send_html_email(subject, body):
    """
    メールを送信する

    Args:
        to_email: 送信先メールアドレス(文字列またはリスト)
        subject: 件名
        body: 本文(html)
    Returns:
        成功した場合はTrue、失敗した場合はFalse
    """
    try:
        to_email = EMAIL_ACCOUNT
        msg = MIMEMultipart('alternative')
        msg['From'] = EMAIL_ACCOUNT
        msg['To'] = to_email
        msg['Subject'] = subject

        # HTMLパートを追加
        html_part = MIMEText(body, 'html', 'utf-8')
        msg.attach(html_part)

         # SMTPサーバーに接続
        server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
        server.starttls()  # TLS暗号化を開始

        # ログイン
        server.login(EMAIL_ACCOUNT, EMAIL_PASSWORD)
        # メールを送信
        server.sendmail(EMAIL_ACCOUNT, to_email, msg.as_string())

        print(f"-- Send email: {to_email}")
        return True

    except smtplib.SMTPException as e:
        print(f"SMTP Error : {e}")
        return False
    except Exception as e:
        print(f"Error : {e}")
        return False
    finally:
        server.quit()      # 接続を閉じる

まとめ

PyJSXを使う事で、無事にPythonでもJSXを使う事ができました。

JSXは現在ではReact以外 SolidQwikInferno等のフロントエンド・フレームワークでも採用されています、Reactの次に使われているVue.jsでもプラグインをインストールするとJSXが使えます。

JavaScrip言語以外でも、今回のPython言語のPyJSXや、Ruby言語(Ruby on Rails)にもruxというJSXライブラリーがあります。
色々な言語・フレームワークでJSXが使えるようになるのは良いですね。😄

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ