私は普段、各サービスのメールをGmailで一括管理するようにしています(複数のメーラを使うのが面倒なので)。
が、当該サービスの終了のお知らせがアナウンスされたため、今回対応することに。
大概のものはGmailのメアドに転送するようにしたのですが、なんとmineoメールには転送機能がありません。
PCを立ち上げている間だけの転送で良ければ、普通にメーラで実現出来ると思います。が、24時間回すファンレスなモノにしたかったこともあり、ESP32評価ボード(アフィリエイトリンクです) で作ってみることに(最初は Raspberry Pi で実装しようと思ったのですが、4Bとか、お高いんですもの)。
久し振りにいきなりコードを書き始めるのもしんどいので、Claude君(無償!w)にあーでもないこーでもないと相談しながら作ったコードが以下のものです(Claude君には転載許可を得ています)。
#include <WiFi.h>
#include <WiFiClient.h>
#include <Preferences.h>
// =====================================================
// 設定
// =====================================================
#define WIFI_SSID "your_ssid"
#define WIFI_PASSWORD "your_wifi_pass"
#define IMAP_HOST "imap.mineo.jp"
#define IMAP_PORT 143
#define IMAP_USER "your_address@mineo.jp"
#define IMAP_PASS "your_password"
#define SMTP_HOST "smtpauth.mineo.jp"
#define SMTP_PORT 587
#define SMTP_USER "your_address@mineo.jp"
#define SMTP_PASS "your_password"
#define FORWARD_TO "destination@example.com"
#define CHECK_INTERVAL (30 * 60 * 1000UL) // 30分毎にチェック
#define NVS_NAMESPACE "mail_fwd"
#define NVS_COUNT_KEY "uid_count"
#define MAX_STORED_IDS 100
#define CHECK_LATEST 20
#define MAX_BODY_BYTES (20 * 1024 * 1024) // 転送上限20MB
// =====================================================
// グローバル
// =====================================================
static WiFiClient g_imap_tcp;
static WiFiClient g_smtp_tcp;
static Preferences prefs;
static int g_tagNum = 1;
static String g_storedUIDs[MAX_STORED_IDS];
static int g_storedCount = 0;
// =====================================================
// ログ
// =====================================================
typedef enum { LOG_INFO, LOG_WARN, LOG_ERROR } LogLevel;
static void logPrint(LogLevel lv, const char* msg) {
static const char* pfx[] = {"[INFO] ","[WARN] ","[ERROR] "};
Serial.print(pfx[lv]); Serial.println(msg);
}
static void logPrint(LogLevel lv, const String &msg) { logPrint(lv, msg.c_str()); }
// =====================================================
// NVS
// =====================================================
static void loadStoredUIDs() {
prefs.begin(NVS_NAMESPACE, true);
g_storedCount = prefs.getInt(NVS_COUNT_KEY, 0);
for (int i = 0; i < g_storedCount; i++) {
String k = "uid_" + String(i);
g_storedUIDs[i] = prefs.getString(k.c_str(), "");
}
prefs.end();
logPrint(LOG_INFO, "NVS 保存済みUID件数: " + String(g_storedCount));
}
static bool isProcessed(const String &uid) {
for (int i = 0; i < g_storedCount; i++)
if (g_storedUIDs[i] == uid) return true;
return false;
}
static void saveUID(const String &uid) {
if (g_storedCount >= MAX_STORED_IDS) {
for (int i = 0; i < g_storedCount - 1; i++)
g_storedUIDs[i] = g_storedUIDs[i+1];
g_storedCount--;
}
g_storedUIDs[g_storedCount++] = uid;
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_COUNT_KEY, g_storedCount);
for (int i = 0; i < g_storedCount; i++) {
String k = "uid_" + String(i);
prefs.putString(k.c_str(), g_storedUIDs[i]);
}
prefs.end();
logPrint(LOG_INFO, "NVS UID保存: " + uid);
}
// static void clearAllUIDs() {
// prefs.begin(NVS_NAMESPACE,false); prefs.clear(); prefs.end();
// g_storedCount=0; logPrint(LOG_WARN,"NVS全削除");
// }
// =====================================================
// WiFi
// =====================================================
static void connectWiFi() {
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
logPrint(LOG_INFO, "WiFi接続中...");
int retry = 0;
while (WiFi.status() != WL_CONNECTED) {
delay(500); Serial.print(".");
if (++retry > 40) { Serial.println(); logPrint(LOG_ERROR,"WiFi失敗。再起動"); ESP.restart(); }
}
Serial.println();
logPrint(LOG_INFO, "WiFi接続完了: " + WiFi.localIP().toString());
}
// =====================================================
// Base64
// =====================================================
static String b64enc(const String &s) {
static const char* t = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
String r;
const int len = s.length();
r.reserve(((len + 2) / 3) * 4 + 2);
for (int i = 0; i < len; i += 3) {
uint8_t a = (uint8_t)s[i];
uint8_t b = (i+1 < len) ? (uint8_t)s[i+1] : 0;
uint8_t c = (i+2 < len) ? (uint8_t)s[i+2] : 0;
r += t[a >> 2];
r += t[((a & 3) << 4) | (b >> 4)];
r += (i+1 < len) ? t[((b & 0xF) << 2) | (c >> 6)] : '=';
r += (i+2 < len) ? t[c & 0x3F] : '=';
}
return r;
}
static int b64val(char c) {
if (c>='A'&&c<='Z') return c-'A';
if (c>='a'&&c<='z') return c-'a'+26;
if (c>='0'&&c<='9') return c-'0'+52;
if (c=='+') return 62; if (c=='/') return 63;
return -1;
}
static String b64dec(const String &src) {
String r; r.reserve(src.length()*3/4+4);
int len=src.length();
for(int i=0;i+3<len;i+=4){
int v0=b64val(src[i]),v1=b64val(src[i+1]);
int v2=b64val(src[i+2]),v3=b64val(src[i+3]);
if(v0<0||v1<0) break;
r+=(char)((v0<<2)|(v1>>4));
if(src[i+2]!='='&&v2>=0) r+=(char)(((v1&0xF)<<4)|(v2>>2));
if(src[i+3]!='='&&v3>=0) r+=(char)(((v2&0x3)<<6)|v3);
}
return r;
}
// =====================================================
// MIMEヘッダーデコード(件名・From表示用)
// =====================================================
static String jisToUtf8(const String &jis) {
String u; u.reserve(jis.length()*2);
int len=jis.length(),i=0; bool k=false;
while(i<len){
uint8_t c=(uint8_t)jis[i];
if(c==0x1B&&i+2<len){
uint8_t c1=jis[i+1],c2=jis[i+2];
if(c1==0x24&&(c2==0x42||c2==0x40)){k=true;i+=3;continue;}
if(c1==0x28){k=false;i+=3;continue;}
}
if(k&&i+1<len){
uint8_t b1=c,b2=jis[i+1];i+=2;
if(b1==0x24&&b2>=0x21&&b2<=0x73){
uint32_t x=0x3040+(b2-0x20);
u+=(char)(0xE0|(x>>12));u+=(char)(0x80|((x>>6)&0x3F));u+=(char)(0x80|(x&0x3F));continue;}
if(b1==0x25&&b2>=0x21&&b2<=0x76){
uint32_t x=0x30A0+(b2-0x20);
u+=(char)(0xE0|(x>>12));u+=(char)(0x80|((x>>6)&0x3F));u+=(char)(0x80|(x&0x3F));continue;}
int row=b1-0x21,col=b2-0x21;
uint8_t s1=(row>>1)+(row<62?0x71:0xB1);if(s1>=0xA0)s1+=0x40;
uint8_t s2=(row&1)?col+0x9F+(col>=0x3F?1:0):col+0x40+(col>=0x3F?1:0);
u+=(char)s1;u+=(char)s2;continue;
}
if(c>=0x20&&c<0x7F)u+=(char)c;
i++;
}
return u;
}
static String decodeMimeHeader(const String &input) {
String result; result.reserve(input.length());
int len=input.length(),i=0;
while(i<len){
int start=input.indexOf("=?",i);
if(start<0){result+=input.substring(i);break;}
if(start>i){String b=input.substring(i,start);if(result.length()>0)b.trim();result+=b;}
int ce=input.indexOf('?',start+2);if(ce<0){result+=input.substring(start);break;}
String charset=input.substring(start+2,ce);charset.toUpperCase();
if(ce+1>=len)break;char enc=input[ce+1];
int ee=input.indexOf('?',ce+2);if(ee<0){result+=input.substring(start);break;}
int te=input.indexOf("?=",ee);if(te<0){result+=input.substring(start);break;}
String et=input.substring(ee+1,te),decoded;
if(enc=='B'||enc=='b') decoded=b64dec(et);
else if(enc=='Q'||enc=='q'){
decoded.reserve(et.length());
for(int j=0;j<(int)et.length();j++){
if(et[j]=='_')decoded+=' ';
else if(et[j]=='='&&j+2<(int)et.length()){char hex[3]={et[j+1],et[j+2],0};decoded+=(char)strtol(hex,nullptr,16);j+=2;}
else decoded+=et[j];
}
} else decoded=et;
if(charset=="ISO-2022-JP"||charset=="ISO-2022-JP-2")result+=jisToUtf8(decoded);
else result+=decoded;
i=te+2;
while(i<len&&(input[i]==' '||input[i]=='\t')&&i+1<len&&input[i+1]=='=')i++;
}
return result;
}
// =====================================================
// 生TCP 共通ヘルパー
// =====================================================
static String tcpReadLine(WiFiClient &tcp, uint32_t timeout_ms = 10000) {
String line; line.reserve(256);
uint32_t t = millis();
while (millis() - t < timeout_ms) {
while (tcp.available()) {
char c = tcp.read();
if (c == '\n') return line;
if (c != '\r') line += c;
t = millis();
}
delay(5);
}
return line;
}
// =====================================================
// IMAP(生TCP)
// =====================================================
static String imapTag() {
char buf[8]; snprintf(buf, sizeof(buf), "T%03d", g_tagNum++);
return String(buf);
}
static String imapCmd(const String &cmd, String *lines = nullptr) {
String tag = imapTag();
g_imap_tcp.print(tag + " " + cmd + "\r\n");
uint32_t t = millis();
while (millis() - t < 15000) {
if (!g_imap_tcp.available()) { delay(10); continue; }
String line = tcpReadLine(g_imap_tcp);
if (line.startsWith(tag)) {
if (line.indexOf(" OK") > 0) return "OK";
if (line.indexOf(" NO") > 0) return "NO";
if (line.indexOf(" BAD") > 0) return "BAD";
return "ERR";
}
if (lines) { *lines += line; *lines += '\n'; }
t = millis();
}
return "TIMEOUT";
}
static String imapFetchRaw(int msgNo, const char* section) {
String cmd;
if (strlen(section) == 0)
cmd = "FETCH " + String(msgNo) + " (BODY.PEEK[])";
else
cmd = "FETCH " + String(msgNo) + " (BODY.PEEK[" + section + "])";
String tag = imapTag();
g_imap_tcp.print(tag + " " + cmd + "\r\n");
String body;
uint32_t t = millis();
while (millis() - t < 30000) {
if (!g_imap_tcp.available()) { delay(10); continue; }
String line = tcpReadLine(g_imap_tcp);
t = millis();
if (line.startsWith(tag)) break;
int lb = line.indexOf('{'), rb = line.indexOf('}');
if (lb >= 0 && rb > lb) {
int byteCount = line.substring(lb+1, rb).toInt();
if (byteCount <= 0 || byteCount > MAX_BODY_BYTES) {
logPrint(LOG_WARN, "FETCHサイズ異常: " + String(byteCount));
break;
}
body.reserve(byteCount + 4);
int rd = 0;
uint32_t bt = millis();
while (rd < byteCount && millis() - bt < 120000) {
if (g_imap_tcp.available()) {
body += (char)g_imap_tcp.read();
rd++;
bt = millis();
} else delay(1);
}
logPrint(LOG_INFO, "FETCH受信: " + String(rd) + "/" + String(byteCount) + " bytes");
uint32_t et2 = millis();
while (millis() - et2 < 5000) {
if (!g_imap_tcp.available()) { delay(5); continue; }
String tail = tcpReadLine(g_imap_tcp);
et2 = millis();
if (tail.startsWith(tag)) break;
}
return body;
}
}
return body;
}
// =====================================================
// SMTP(生TCP)
// =====================================================
static String smtpRead(uint32_t timeout_ms = 10000) {
String last;
uint32_t t = millis();
while (millis() - t < timeout_ms) {
if (!g_smtp_tcp.available()) { delay(10); continue; }
String line = tcpReadLine(g_smtp_tcp);
t = millis();
last = line;
if (line.length() >= 4 && line[3] == ' ') break;
}
return last;
}
static bool smtpExpect(int code) {
String r = smtpRead();
Serial.println("SMTP<< " + r);
return r.startsWith(String(code));
}
static void smtpSend(const String &s) {
Serial.println("SMTP>> " + s);
g_smtp_tcp.print(s + "\r\n");
}
static void smtpWriteStuffed(const String &s) {
int len = s.length();
bool lineStart = true;
for (int i = 0; i < len; i++) {
char c = s[i];
if (lineStart && c == '.') g_smtp_tcp.write('.');
g_smtp_tcp.write(c);
lineStart = (c == '\n');
}
}
// =====================================================
// 転送送信
// fullMail = BODY.PEEK[]で取得したRFC822全体
// =====================================================
static bool sendForwardMail(
const String &subject,
const String &from,
const String &date,
const String &fullMail)
{
if (!g_smtp_tcp.connect(SMTP_HOST, SMTP_PORT)) {
logPrint(LOG_ERROR, "SMTP TCP接続失敗"); return false;
}
if (!smtpExpect(220)) {
logPrint(LOG_ERROR, "SMTPグリーティング失敗"); g_smtp_tcp.stop(); return false;
}
smtpSend("EHLO esp32.local");
{
uint32_t t = millis();
while (millis() - t < 5000) {
if (!g_smtp_tcp.available()) { delay(10); continue; }
String line = tcpReadLine(g_smtp_tcp);
Serial.println("SMTP<< " + line);
t = millis();
if (line.length() >= 4 && line[3] == ' ') break;
}
}
smtpSend("AUTH LOGIN");
if (!smtpExpect(334)) { logPrint(LOG_ERROR,"AUTH LOGIN失敗"); g_smtp_tcp.stop(); return false; }
smtpSend(b64enc(String(SMTP_USER)));
if (!smtpExpect(334)) { logPrint(LOG_ERROR,"USERNAME失敗"); g_smtp_tcp.stop(); return false; }
smtpSend(b64enc(String(SMTP_PASS)));
if (!smtpExpect(235)) { logPrint(LOG_ERROR,"SMTP認証失敗"); g_smtp_tcp.stop(); return false; }
logPrint(LOG_INFO, "SMTP認証成功");
smtpSend("MAIL FROM:<" + String(SMTP_USER) + ">");
if (!smtpExpect(250)) { logPrint(LOG_ERROR,"MAIL FROM失敗"); g_smtp_tcp.stop(); return false; }
smtpSend("RCPT TO:<" + String(FORWARD_TO) + ">");
if (!smtpExpect(250)) { logPrint(LOG_ERROR,"RCPT TO失敗"); g_smtp_tcp.stop(); return false; }
smtpSend("DATA");
if (!smtpExpect(354)) { logPrint(LOG_ERROR,"DATA失敗"); g_smtp_tcp.stop(); return false; }
// RFC822のヘッダー部とボディ部を分割
int sep = fullMail.indexOf("\r\n\r\n");
bool crlf = true;
if (sep < 0) { sep = fullMail.indexOf("\n\n"); crlf = false; }
String hdrPart = (sep >= 0) ? fullMail.substring(0, sep) : fullMail;
String bdyPart = (sep >= 0) ? fullMail.substring(sep + (crlf ? 4 : 2)) : "";
// Resent-*ヘッダーを先頭に追加(RFC5322準拠の転送形式)
g_smtp_tcp.print("Resent-From: " + String(SMTP_USER) + "\r\n");
g_smtp_tcp.print("Resent-To: " + String(FORWARD_TO) + "\r\n");
g_smtp_tcp.print("Resent-Date: " + date + "\r\n");
g_smtp_tcp.print("X-Forwarded-To: " + String(FORWARD_TO) + "\r\n");
// 元ヘッダーを1行ずつ処理
// To:のみ転送先に差し替え
// Subject:に[転送]プレフィックス付加
// Return-Path:/Delivered-To:/Received:/DKIM-Signature:は除去
bool skipFolded = false;
int hpos = 0, hlen = hdrPart.length();
while (hpos < hlen) {
int nl = hdrPart.indexOf('\n', hpos);
int end = (nl < 0) ? hlen : nl + 1;
String hline = hdrPart.substring(hpos, end);
hpos = end;
// 折り畳み継続行
if (hline.length() > 0 && (hline[0] == ' ' || hline[0] == '\t')) {
if (!skipFolded) smtpWriteStuffed(hline);
continue;
}
// 新しいヘッダーフィールド
String hlineLower = hline; hlineLower.toLowerCase();
// To: のみ転送先に差し替え
if (hlineLower.startsWith("to:")) {
g_smtp_tcp.print("To: " + String(FORWARD_TO) + "\r\n");
skipFolded = true;
continue;
}
// Subject: に[転送]プレフィックス付加
if (hlineLower.startsWith("subject:")) {
g_smtp_tcp.print("Subject: =?UTF-8?B?" + b64enc("[転送] " + subject) + "?=\r\n");
skipFolded = true;
continue;
}
// 除去するヘッダー
if (hlineLower.startsWith("return-path:") ||
hlineLower.startsWith("delivered-to:") ||
hlineLower.startsWith("received:") ||
hlineLower.startsWith("dkim-signature:")) {
skipFolded = true;
continue;
}
skipFolded = false;
smtpWriteStuffed(hline);
}
// ヘッダーとボディの区切り空行
g_smtp_tcp.print("\r\n");
// ボディをそのまま送信
smtpWriteStuffed(bdyPart);
if (bdyPart.length() == 0 || bdyPart[bdyPart.length()-1] != '\n')
g_smtp_tcp.print("\r\n");
g_smtp_tcp.print(".\r\n");
if (!smtpExpect(250)) { logPrint(LOG_ERROR,"DATA終端失敗"); g_smtp_tcp.stop(); return false; }
smtpSend("QUIT");
smtpExpect(221);
g_smtp_tcp.stop();
return true;
}
// =====================================================
// メイン転送処理
// =====================================================
static void forwardMails() {
logPrint(LOG_INFO, "====== メール転送処理 開始 ======");
g_tagNum = 1;
if (!g_imap_tcp.connect(IMAP_HOST, IMAP_PORT)) {
logPrint(LOG_ERROR, "IMAP TCP接続失敗"); return;
}
tcpReadLine(g_imap_tcp);
if (imapCmd(String("LOGIN ") + IMAP_USER + " " + IMAP_PASS) != "OK") {
logPrint(LOG_ERROR, "IMAPログイン失敗"); g_imap_tcp.stop(); return;
}
logPrint(LOG_INFO, "IMAPログイン成功");
String selLines;
if (imapCmd("SELECT INBOX", &selLines) != "OK") {
logPrint(LOG_ERROR, "INBOX選択失敗"); g_imap_tcp.stop(); return;
}
int totalMsg = 0;
{
int idx = selLines.indexOf(" EXISTS");
if (idx > 0) {
int nl = selLines.lastIndexOf('\n', idx);
String numStr = selLines.substring(nl+1, idx);
numStr.trim();
if (numStr.startsWith("* ")) numStr = numStr.substring(2);
totalMsg = numStr.toInt();
}
}
logPrint(LOG_INFO, "総件数: " + String(totalMsg));
if (totalMsg == 0) {
logPrint(LOG_INFO, "メールなし");
imapCmd("LOGOUT"); g_imap_tcp.stop();
logPrint(LOG_INFO, "====== メール転送処理 終了 ======");
return;
}
int startNum = max(1, totalMsg - CHECK_LATEST + 1);
logPrint(LOG_INFO, "処理範囲: " + String(startNum) + "~" + String(totalMsg));
for (int msgNo = totalMsg; msgNo >= startNum; msgNo--) {
// UID取得
String flagLines;
imapCmd("FETCH " + String(msgNo) + " (UID FLAGS)", &flagLines);
String uid;
{
int ui = flagLines.indexOf("UID ");
if (ui >= 0) {
int ue = flagLines.indexOf(' ', ui+4);
if (ue < 0) ue = flagLines.indexOf(')', ui+4);
if (ue > ui+4) uid = flagLines.substring(ui+4, ue);
}
uid.trim();
if (uid.isEmpty()) uid = "seq_" + String(msgNo);
}
logPrint(LOG_INFO, "--- msgNo:" + String(msgNo) + " UID:" + uid + " ---");
if (isProcessed(uid)) { logPrint(LOG_INFO, "スキップ(処理済)"); continue; }
// ENVELOPE取得(件名・From・Date)
String envLines;
imapCmd("FETCH " + String(msgNo) + " (ENVELOPE)", &envLines);
String subject, from, date;
{
int ei = envLines.indexOf("ENVELOPE (");
if (ei >= 0) {
int pos = ei + 10;
auto nextQuoted = [&](int &p) -> String {
int s = envLines.indexOf('"', p); if (s < 0) return "";
int e = envLines.indexOf('"', s+1);
while (e > 0 && envLines[e-1] == '\\') e = envLines.indexOf('"', e+1);
if (e < 0) return ""; p = e+1;
return envLines.substring(s+1, e);
};
date = nextQuoted(pos);
subject = decodeMimeHeader(nextQuoted(pos));
int fb = envLines.indexOf("((\"", pos);
if (fb < 0) fb = envLines.indexOf("((NIL", pos);
if (fb >= 0) {
int fe = envLines.indexOf("))", fb);
if (fe > fb) {
String fromBlock = envLines.substring(fb+2, fe);
int fp = 0;
String fname = nextQuoted(fp);
int nilPos = fromBlock.indexOf("NIL");
int uq = fromBlock.indexOf('"', nilPos < 0 ? 0 : nilPos+3);
String fuser, fhost;
if (uq >= 0) {
int ue2 = fromBlock.indexOf('"', uq+1);
fuser = fromBlock.substring(uq+1, ue2);
int hq = fromBlock.indexOf('"', ue2+1);
if (hq >= 0) {
int he = fromBlock.indexOf('"', hq+1);
fhost = fromBlock.substring(hq+1, he);
}
}
from = decodeMimeHeader(fname);
if (fuser.length() > 0 && fhost.length() > 0)
from += " <" + fuser + "@" + fhost + ">";
}
}
}
}
logPrint(LOG_INFO, "件名: " + subject);
logPrint(LOG_INFO, "From: " + from);
// RFC822全体を取得
logPrint(LOG_INFO, "メール取得中...");
String fullMail = imapFetchRaw(msgNo, "");
logPrint(LOG_INFO, "メール全体長: " + String(fullMail.length()) + " bytes");
// 転送
logPrint(LOG_INFO, "転送中...");
bool fwd = sendForwardMail(subject, from, date, fullMail);
if (fwd) {
logPrint(LOG_INFO, "転送成功");
saveUID(uid);
} else {
logPrint(LOG_ERROR, "転送失敗");
}
delay(300);
}
imapCmd("LOGOUT");
g_imap_tcp.stop();
logPrint(LOG_INFO, "====== メール転送処理 終了 ======");
}
// =====================================================
// Setup / Loop
// =====================================================
void setup() {
Serial.begin(115200); delay(1000);
Serial.println("\n=== ESP32 メール自動転送システム 起動 ===");
// clearAllUIDs();
loadStoredUIDs();
connectWiFi();
forwardMails();
}
void loop() {
if (WiFi.status() != WL_CONNECTED) { logPrint(LOG_WARN,"WiFi切断。再接続"); connectWiFi(); }
logPrint(LOG_INFO, "次回まで " + String(CHECK_INTERVAL/60000) + " 分待機");
delay(CHECK_INTERVAL);
forwardMails();
}
仕様と補足
- mineoに届いたメールをIMAPで受け、mineoのSMTPサーバを使って転送します
- 8行目から20行目の定数に、ご自分のパラメータを嵌め込んでください
- 30分毎に未読チェックに行きます
- 転送最大サイズは20MBにしています
- 既読フラグをNVSに置いています
- スタックが8KBしか無い環境なので、なるべく関数内部にはモノを溜め込まないようにしました
- mineoのIMAP実装が特殊?だったため、ESP32 Mail Clientが使えませんでした(ここが一番の苦労点)
- 一部のメーラで作られたメールだと、本文転送が出来ないようです orz…
何かの役に立てば…。