Skip to content

POP 受信の本体組み込み(Quartz タイマー・テナント設定)の設計 ​

第1版作成 最終更新 (日本時間)
確認バージョン1.5.8.1

プリザンターはメールを SMTP で送信できますが、POP3 / IMAP で受信する機能はありません。外部スクリプトで取り込む方法と、パラメータファイルとスレッドで動かす簡易な本体改修は POP 受信でメールをレコードに取り込む にまとめています。

このページは、それをもう一歩進めて、本体の Quartz タイマーとテナント設定に組み込む場合の設計メモです。テナントごとに受信箱と取り込み先を設定でき、重複防止を専用テーブルで行います。本体の標準機能ではありません。

前提にした現行実装(1.5.8.1) ​

メール送信の基盤 ​

項目1.5.8.1
ライブラリMailKit 4.17.0(Implem.Pleasanter.csproj)。POP3 / IMAP のクライアント(Pop3Client / ImapClient)も同じパッケージに入っているが、本体では使っていない
送信Smtp.cs が SmtpClient で送る。Mail.json の SecureSocketOptions が解釈できればそれを使い、無ければ SmtpEnableSsl で StartTls か None を選ぶ。ServerCertificateValidationCallback が true なら証明書の検証を省く。UseOAuth なら OAuth2(SaslMechanismOAuth2)で認証する(Smtp.cs)
送信記録OutgoingMails テーブル

POP3 の接続・認証も、この SMTP と同じ考え方(ソケットの暗号化方式の解釈、OAuth2 の流用)で組めます。

Quartz のタイマー ​

項目1.5.8.1
登録各タイマーの Param(IExecutionTimerBaseParam)を TimerBackground の一覧に並べる(TimerBackground.cs)
起動条件一覧全体は BackgroundService.TimerEnabled(既存タイマーの有効フラグの OR)が true のときだけ動く(BackgroundService.cs)。新しいタイマーを足すときはここにも条件を足す
時刻指定TimeList("02:00" のような HH:mm)を返すと毎日その時刻に動く
間隔指定TimeList を null にして SetCustomTimer で SimpleSchedule を登録する。リマインダーは ReminderCheckIntervalSeconds(既定 60 秒)を 30〜3600 秒に丸め、範囲外なら SysLogs に警告を出す(ReminderBackgroundTimer.cs)
多重実行の防止リマインダーは静的な IsRunning フラグで、前回の処理中なら何もしない

レコードの作成 ​

サーバースクリプトの items.Create の実体 ServerScriptUtilities.Create(context, id, model) は、API 用の Context を作って ItemModel.CreateByServerScript を呼びます(ServerScriptUtilities.cs)。

  • model は JSON 文字列か ServerScriptModelApiModel で渡す。記録テーブル・期限付きテーブルでそれ以外のオブジェクトを渡すと ToString() の結果が本文になる(ItemModel.cs)ので、C# の Dictionary はそのまま渡せない
  • 作成の前にサイトの API 上限(WithinApiLimits)を確認する(ItemModel.cs)

POP 受信でメールをレコードに取り込む の本体改修方式では、API と同じ入力チェック・権限チェック・通知を通すために ItemModel.CreateByApi を使っています。どちらを使っても、本文は API と同じ JSON 文字列にします。

全体の構成 ​

図を読み込み中…

図を読み込み中…

パラメータ ​

サーバー全体のスイッチと間隔を BackgroundService.json に、受信箱ごとの設定はテナント設定に持たせます。

パラメータ既定説明
BackgroundService.PopMailPollingfalse受信タイマーの有効・無効
BackgroundService.PopMailPollingIntervalSeconds300受信の間隔(秒)。リマインダーと同じく下限・上限で丸める
Mail.Pop3MaxMessagesPerPoll501 回に処理する最大件数

受信箱の設定をどこに置くかは 3 通り考えられます。

方式利点欠点
A. Mail.json に 1 つ最も簡単テナントごとに受信箱を分けられない
B. テナント設定(推奨)テナントごとに受信箱と取り込み先を持てる設定画面が要る
C. サイト設定テーブルごとに設定できる設定が散らばる

B は、バックグラウンドサーバースクリプトが TenantSettings.BackgroundServerScripts に入っているのと同じ形です(TenantSettings.cs)。

csharp
public class PopMailSetting : ISettingListItem
{
    public int Id { get; set; }
    public string Pop3Host;
    public int Pop3Port = 995;
    public string Pop3UserName;
    public string Pop3Password;
    public string Pop3SecureSocketOptions;   // 例: SslOnConnect
    public bool Pop3UseOAuth;
    public long TargetSiteId;                 // 取り込み先
    public int ExecutionUserId;               // レコードの作成者
    public bool DeleteAfterReceive;
    public string SubjectFilter;              // 件名に含む文字列
    public string FromFilter;                 // 差出人に含む文字列
    public PopMailColumnMapping ColumnMapping;
    public bool Disabled;
}

public class PopMailColumnMapping
{
    public string SubjectColumn = "Title";
    public string BodyColumn = "Body";
    public string FromColumn = "ClassA";
    public string ToColumn = "ClassB";
    public string DateColumn = "DateA";
    public string MessageIdColumn = "ClassC";
    public string CcColumn;
    public string AttachmentsColumn;          // 例: AttachmentsA
}

受信 ​

MailKit の Pop3Client で受けます。

API内容
ConnectAsync / AuthenticateAsync接続と認証(パスワードまたは OAuth2)
Countサーバー上の件数
GetMessageUidAsync(i)UID
GetMessageAsync(i)MimeMessage(From・To・Cc・Subject・Date・MessageId・TextBody・HtmlBody・Attachments・Headers)
DeleteMessageAsync(i)削除の印を付ける
DisconnectAsync(quit: true)切断。ここで削除が確定する
csharp
using var client = new Pop3Client();
var options = Enum.TryParse<SecureSocketOptions>(setting.Pop3SecureSocketOptions, out var op)
    ? op
    : SecureSocketOptions.SslOnConnect;
await client.ConnectAsync(setting.Pop3Host, setting.Pop3Port, options);
if (setting.Pop3UseOAuth)
{
    await client.AuthenticateAsync(new SaslMechanismOAuth2(setting.Pop3UserName, accessToken));
}
else
{
    await client.AuthenticateAsync(setting.Pop3UserName, setting.Pop3Password);
}
var count = Math.Min(client.Count, Parameters.Mail.Pop3MaxMessagesPerPoll);
for (var i = 0; i < count; i++)
{
    var uid = await client.GetMessageUidAsync(i);
    if (IsImported(setting, uid)) continue;
    var message = await client.GetMessageAsync(i);
    // フィルタ → レコード作成 → UID の記録 → 必要なら DeleteMessageAsync(i)
}
await client.DisconnectAsync(quit: true);

重複取り込みの防止 ​

方式利点欠点
UID を専用テーブルで管理(推奨)確実。取り込み先の項目を使わないテーブルが増える
Message-ID を取り込み先の項目に保存して照合テーブル追加が要らない取り込み先に項目が要る
取り込んだらサーバーから削除最も簡単失敗したときにメールを失うおそれ
sql
CREATE TABLE PopMailUids (
    TenantId          INT            NOT NULL,
    PopMailSettingId  INT            NOT NULL,
    Uid               NVARCHAR(512)  NOT NULL,
    MessageId         NVARCHAR(998)  NULL,
    CreatedTime       DATETIME       NOT NULL,
    CONSTRAINT PK_PopMailUids PRIMARY KEY (TenantId, PopMailSettingId, Uid)
);
CREATE INDEX IX_PopMailUids_CreatedTime ON PopMailUids (CreatedTime);

実装では CodeDefiner の定義に追加します。古い UID(90 日以上前など)は定期的に削除します。フィルタで対象外にしたメールも UID を記録しておくと、毎回取得し直さずに済みます。

レコードへの対応付け ​

メール既定の項目
件名タイトル
本文(TextBody、空なら HtmlBody)内容
差出人分類 A
宛先分類 B
送信日時日付 A
Message-ID分類 C
添付ファイル添付ファイル項目(AttachmentsHash に Base64 で渡す)

実行ユーザー(ExecutionUserId)の Context は、バックグラウンドサーバースクリプトと同じ手順で作ります(手順は iCal フィードの設計 を参照)。レコードは API と同じ JSON を組み立て、文字列にしてから渡します。

csharp
var mapping = setting.ColumnMapping;
var request = new Dictionary<string, object>
{
    ["Title"] = mail.Subject,
    ["Body"] = mail.TextBody ?? mail.HtmlBody ?? string.Empty,
    ["ClassHash"] = new Dictionary<string, string>
    {
        [mapping.FromColumn] = mail.From.ToString(),
        [mapping.ToColumn] = mail.To.ToString(),
        [mapping.MessageIdColumn] = mail.MessageId
    },
    ["DateHash"] = new Dictionary<string, string>
    {
        [mapping.DateColumn] = mail.Date.UtcDateTime.ToString("o")
    }
};
// 添付があれば request["AttachmentsHash"] に { Name, ContentType, Base64 } の配列を入れる
ServerScriptUtilities.Create(
    context: execContext,
    id: setting.TargetSiteId,
    model: request.ToJson());

添付ファイルのサイズ上限は既存の添付ファイルの設定に従い、超えるものは飛ばして SysLogs に警告を残します。

フィルタ ​

項目例動作
SubjectFilter[問い合わせ]件名にこの文字列を含むメールだけ取り込む
FromFilter@example.com差出人にこの文字列を含むメールだけ取り込む

空なら全件を取り込み、両方あれば AND で判定します。

タイマー ​

csharp
class PopMailPollingTimer : ClusterExecutionTimerBase
{
    static private bool IsRunning = false;

    public class Param : IExecutionTimerBaseParam
    {
        public static readonly JobKey jobKey = new JobKey("PopMailPollingTimer", "ExecutionTimerBase");
        public Type JobType => typeof(PopMailPollingTimer);
        public IEnumerable<string> TimeList => null;
        public bool Enabled => Parameters.BackgroundService.PopMailPolling;
        public JobKey JobKey => jobKey;
        public string JobName => "PopMailPollingService";
        public async Task<bool> SetCustomTimer(IScheduler scheduler)
        {
            var seconds = Math.Clamp(Parameters.BackgroundService.PopMailPollingIntervalSeconds, 60, 3600);
            var triggerKey = TimerTriggerRegistrar.SimpleTriggerKey(JobKey);
            var trigger = TriggerBuilder.Create()
                .WithIdentity(triggerKey)
                .ForJob(JobKey)
                .WithSimpleSchedule(x => x.WithIntervalInSeconds(seconds).RepeatForever())
                .Build();
            await TimerTriggerRegistrar.EnsureTriggerAsync(scheduler: scheduler, trigger: trigger);
            return true;
        }
    }

    public override async Task Execute(IJobExecutionContext context)
    {
        if (IsRunning) return;
        await Task.Run(async () =>
        {
            if (IsRunning) return;
            var sysContext = CreateContext();
            try
            {
                IsRunning = true;
                await PopMailPollingUtilities.PollAsync(context: sysContext);
            }
            catch (Exception e)
            {
                _ = new SysLogModel(context: sysContext, e: e, extendedErrorMessage: "PopMailPollingService Exception");
            }
            finally
            {
                IsRunning = false;
            }
        }, context.CancellationToken);
    }

    internal static IExecutionTimerBaseParam GetParam() => new Param();
}

変更箇所 ​

ファイル内容
Implem.ParameterAccessor/Parts/BackgroundService.csPopMailPolling・PopMailPollingIntervalSeconds を追加し、TimerEnabled の条件にも足す
Implem.ParameterAccessor/Parts/Mail.cs・Mail.jsonPop3MaxMessagesPerPoll
App_Data/Parameters/BackgroundService.json既定値
Libraries/BackgroundServices/PopMailPollingTimer.csタイマー(新規)
Libraries/BackgroundServices/PopMailPollingUtilities.csテナントごとの受信処理(新規)
Libraries/BackgroundServices/TimerBackground.cs一覧に PopMailPollingTimer.GetParam() を追加
Libraries/Settings/TenantSettings.csPopMailSettings を追加
Models/Tenants/TenantUtilities.csテナントの管理画面に設定タブ
CodeDefiner の定義PopMailUids テーブル

セキュリティ ​

項目対策
パスワードの保管テナント設定に平文で入れず暗号化する
暗号化既定は SslOnConnect(995 番)
OAuth2SMTP の OAuth2 の仕組みを流用する
悪意のある内容HTML 本文の無害化、添付ファイルのウイルス検査との連携を検討する
大量受信Pop3MaxMessagesPerPoll で 1 回の件数を制限する
証明書の検証SMTP と同じ ServerCertificateValidationCallback で制御する

代替案: バックグラウンドサーバースクリプトと中継サービス ​

C# の改修を避けるなら、POP3 を受けて JSON で返す小さな中継サービスを外に置き、バックグラウンドサーバースクリプトから httpClient で取りに行く方法もあります。

図を読み込み中…

javascript
// バックグラウンドサーバースクリプト(関数化をオン)
var targetSiteId = 12345;
httpClient.RequestUri = 'https://mail-bridge.example.com/api/mails';
var response = httpClient.Get();
if (!httpClient.IsSuccess) {
    logs.LogInfo('メール取得に失敗: ' + httpClient.StatusCode);
    return;
}
JSON.parse(response).forEach(function (mail) {
    items.Create(targetSiteId, JSON.stringify({
        Title: mail.subject,
        Body: mail.body,
        ClassHash: { ClassA: mail.from, ClassB: mail.to },
        DateHash: { DateA: mail.date }
    }));
});

httpClient.Get() は応答の本文を戻り値で返します。バックグラウンドサーバースクリプトでは画面が無いので、記録は context.Log ではなく logs.LogInfo(SysLogs)に残します(RSS リーダー も参照)。

観点本体に組み込む中継サービス + スクリプト
開発量大きい(C#・テーブル追加)小さい
保守バージョンアップのたびに追従本体と独立
信頼性本体のログ・障害管理に乗る中継サービスの可用性に依存
添付ファイルそのまま扱える中継側で Base64 にする
テナント分離テナント設定で自然に分かれる中継側で管理する

関連ページ ​

変更履歴

第1版外部連携の改修・設計メモ(iCal・RSS/Atom・Webhook 送受信・iPaaS・短縮 URL・POP 受信・マスターデータ同期)を追加