Exchange OnlineでSMTP AUTH使わずSMTPにしか対応しない機器からも送信する

## SMTP AUTHとは

昔ならがのユーザーIDとパスワードで認証する方式。
既にOutlookからExchange Onlineへの接続には使用できず、複合機とかネットワーク機器から自動送信とかする場合に使う。

https://learn.microsoft.com/ja-jp/exchange/mail-flow-best-practices/how-to-set-up-a-multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365

MS365のセキュリティ設定をまとめて弄ると無効化されるらしいし、

https://learn.microsoft.com/ja-jp/entra/fundamentals/security-defaults

ユーザーIDとパスワードをそこら中に設定するのも問題だし
と思って対策考えていたら。

もっと重大な問題になっていた。今後完全に無効化されるらしい。

https://techcommunity.microsoft.com/t5/exchange-team-blog/exchange-online-to-retire-basic-auth-for-client-submission-smtp/ba-p/4114750


このページによると対策は3パターン。

> * If you are using Basic auth with Client Submission (SMTP AUTH) to send emails to recipients internal to your tenant, you can use High Volume Email for Microsoft 365.
> * If you are using Basic auth with Client Submission (SMTP AUTH) to send emails to recipients internal and external to your tenant, you can use Azure Communication Services Email.
> * If you have an Exchange Server on-premises in a hybrid configuration, you can use Basic auth to authenticate with the Exchange Server on-premises or configure the Exchange Server on-premises with a Receive connector that Allow anonymous relay on Exchange servers | Microsoft Learn


### High Volume Email for Microsoft 365.
テナント内アカウントであれば、送信用アカウントを設定することでほぼ今まで通りに出来るらしい。
ただし、パブリックプレビューでいつでも中断・無効化の可能性があるとの事。
20ユーザー作れるらしいので、ユーザーIDとパスワードのバラマキにも対応出来そう。

### Azure Communication Services Email
ちょっと見ただけでは分かりづらいけど、Azureのアプリケーションで同じようなことをやる、って感じか？


### Exchange Server
オンプレのExchange Server使って転送する手があるよ。
でもライセンス費用が掛かるのでそれだけのためには使えない。

といった感じに一応SMTPを使いづける手はあるみたい、ただし通常のメールアカウントを使う方法は無くなるよう。
基本的にはこれらで対応するのが正道だと思うけど、これを見る前にやっちゃったのでそれを紹介。

***
それにしても

> Wait! I still need to use Basic auth; how can I get it re-enabled in my tenant once it gets disabled in September 2025?
> You will not be able to do this because Basic auth will be permanently disabled. Don’t waste your time engaging Support, as they cannot re-enable Basic auth for you.
> 
> What about an exception?! 
> We cannot offer any exceptions; Basic auth will be permanently disabled. Please do not reach out to Support either, as they cannot grant an exception for you to use Basic auth.

永久に全て無効化するんだよ、それが決定だ、何言ってきても無駄だからな、手間かけさせんなよ。
って感じなの強い。
***

## 普通に存在するメールアカウント相当でメール送信するには？


https://learn.microsoft.com/ja-jp/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online#impact-to-messaging-protocols-and-existing-applications

> 認証されたメールを送信するためのその他のオプションには、Microsoft Graph API などの代替プロトコルの使用があります。

だそうです。


そんなわけで社内にSMTP→MS Graph API転送サーバーを作ってみた。

:::note info
MS Graph APIでメール送信するにはアプリケーションへのAPIアクセス許可の他に
ExchangeOnlineの方でアクセス許可が必要です。

https://learn.microsoft.com/ja-jp/graph/auth-limit-mailbox-access

これ結構ハマりやすいので。
:::

:::note warn
作った転送サーバーは他にExchangeOnlineの制限を超える一斉送信するために直接SMTP送信する機能も付いてるけどそこは省略しています。
また、省略で削ったり掲載用に少し弄っているけどその状態で動作確認していないのでそのまま動くかはわかりません。
:::

依存モジュール
* smtp-server
* mailparser
* nodemailer
* @microsoft/microsoft-graph-client
* @azure/identity

<details><summary>コード</summary>

```typescript:server.ts
import {msGrapthAPI,MailRecipient,MailMessege} from "./msGraph";
import {SMTPServer,SMTPServerSession,SMTPServerAuthentication,SMTPServerAuthenticationResponse,SMTPServerDataStream, SMTPServerOptions} from "smtp-server";
import {simpleParser} from "mailparser";
import { Stream } from 'nodemailer/lib/xoauth2';
import {addressObjToAddressList,AddressObj,isAddressObject,isEmailAddress} from "./utils";

const config:{
    server:{
        port:number
    }
    user:Array<{
        user:string,
        pass:string,
    }>,
    allowHosts:Array<string>,
    msGraphOptions:{
        TenantID:string,
        ClientID:string,
        ClientSecret:string,
    },
    graphApiSendMailGroupID:string,
} = {
    //SMTPサーバーとしての設定
    "server":{
        "port":25
    },
    //SMTP認証用ユーザーIDとパスワード
    "user":[{
        "user":"msgraph",
        "pass":"msgraph",
    }],
    //アクセス許可ホスト
    "allowHosts":[],
    //Microsoft Graph APIのアクセストークン
    "msGraphOptions":{
        "TenantID":"",
        "ClientID":"",
        "ClientSecret":""
    },
    //アプリのアクセスを許可したメールが有効なセキュリティグループ
    "graphApiSendMailGroupID":""
}


const onConnect = (session: SMTPServerSession, callback: (err?: Error | null | undefined) => void) => {
    if(config.allowHosts.length >0 && !config.allowHosts.includes(session.remoteAddress)){
        callback(new Error("Host access denied"));
    }
    callback();
}
const onAuth = (auth: SMTPServerAuthentication,
    session: SMTPServerSession,
    callback: (err: Error | null | undefined,
    response?: SMTPServerAuthenticationResponse | undefined) => void
    ) => {
        const authUser = config.user.find(v=>{if(v.user === auth.username && v.pass === auth.password){return true;}});
        callback(null,{"user":authUser?.user});
}

const onData = (stream: SMTPServerDataStream, session: SMTPServerSession, callback: (err?: Error | null | undefined) => void) => {
    mailRelay(stream,session,callback);
}

const mailRelay = async (stream: SMTPServerDataStream, session: SMTPServerSession, callback: (err?: Error | null | undefined) => void)=>{
    const mailData = await stremToString(stream);
    const mail = await simpleParser(mailData);
    const envelopeTo = addressObjToAddressList([mail.to,mail.cc]);
    const bcc = session.envelope.rcptTo.map(v=>v.address).filter(v=>!envelopeTo.includes(v));
    const mailFrom = session.envelope.mailFrom;
    if(!mailFrom){
        callback(new Error("MAIL:Fail,MAIL FROM  invalid."));
        return;
    }
    let sendSccess = false;
    try{
        const msg = new msGrapthAPI(config.msGraphOptions.TenantID,config.msGraphOptions.ClientID,config.msGraphOptions.ClientSecret);
        const sendMailUsers = await msg.getGroupMembers(config.graphApiSendMailGroupID);
        const sender = sendMailUsers && sendMailUsers.value.find(v=>{
            if(v.userPrincipalName === mailFrom.address){
                return true;
            }});
        if(sender){
            //MS Graph API sendMail
            const toRecipients = addressObjectToRecipient(mail.to);
            const ccRecipients = addressObjectToRecipient(mail.cc);
            const bccRecipients = addressObjectToRecipient(bcc);
            if(toRecipients.length+ccRecipients.length+bccRecipients.length>0){
                const messege:MailMessege = {
                    sender:{"emailAddress":{"address":sender.mail}},
                    subject:mail.subject||"",
                    body:{"contentType":mail.html?"html":"text","content":mail.html||mail.text||""}
                }
                if(toRecipients.length>0){
                    messege.toRecipients = toRecipients;
                }
                if(ccRecipients.length>0){
                    messege.ccRecipients = ccRecipients;
                }
                if(bccRecipients.length>0){
                    messege.bccRecipients = bccRecipients;
                }
                if(mail.attachments.length>0){
                    messege.attachments = [];
                    for(const att of mail.attachments){
                        if(att.filename){
                            messege.attachments.push({
                                "@odata.type":"#microsoft.graph.fileAttachment",
                                "name":att.filename,
                                "contentBytes":att.content.toString("base64"),
                            });
                        }
                    }
                }
                msg.sendMail(messege);
                sendSccess = true;
            }
        }
    }catch(err){
        console.error("MSGraph API Error:");
        console.error(err);
    }
    if(sendSccess){
        callback(null);
    }else{
        callback(new Error("Mail relay failure."))
    }
}

const addressObjectToRecipient = (address?:AddressObj|string|Array<string>) => {
    if(!address){
        return [];
    }
    const mailRecipient:Array<MailRecipient> = [];
    const addressObjList = Array.isArray(address)?address:[address];
    for(const adr of addressObjList){
        if(typeof adr === "string"){
            mailRecipient.push({"emailAddress":{"address":adr}});
        }else if(isEmailAddress(adr) && adr.address){
            mailRecipient.push({"emailAddress":{"address":adr.address}});
        }else if(isAddressObject(adr)){
            for(const adrVal of adr.value){
                if(adrVal.address){
                    const adrRecip:MailRecipient = {emailAddress:{
                        "address":adrVal.address
                    }};
                    if(adrVal.name){
                        adrRecip.emailAddress.name = adrVal.name;
                    }
                    mailRecipient.push(adrRecip);
                }
            }
        }
    }
    return mailRecipient;
}


const stremToString = (stream:Stream):Promise<string> => {
    const chunks:Array<any> = [];
    return new Promise((resolve, reject) => {
        stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
        stream.on('error', (err) => reject(err));
        stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
    });
}

const serverOptions:SMTPServerOptions = {
    authOptional:true,
    secure:false,
    onConnect,
    onAuth,
    onData
};

const init = () =>{
    const server = new SMTPServer(serverOptions);
    try{
        server.on("error",(err)=>{
            console.error("sever.on error:");
            console.error(err);
        });
        server.listen(config.server.port,()=>{
            console.info(`server.listen:${config.server.port}`);
        });
    }catch(e){
        console.error("System error.")
        console.error(e);
        console.error("sever. restart");
        init();
    }
}
init();
```

```typescript:msGraph.ts
import {Client,AuthenticationProvider,AuthenticationProviderOptions} from "@microsoft/microsoft-graph-client";
import {ClientSecretCredential} from "@azure/identity";

interface GroupMembers{
    "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#directoryObjects",
    "value":Array<MicrosoftGraphUser>,
}

interface MicrosoftGraphUser{
    "@odata.type": "#microsoft.graph.user",
    "id": string,
    "businessPhones":Array<string>,
    "displayName": string,
    "givenName": string,
    "jobTitle": null|string,
    "mail": string,
    "mobilePhone": null|string,
    "officeLocation": string,
    "preferredLanguage": string,
    "surname": string,
    "userPrincipalName": string
}

export interface MailRecipient{
    emailAddress:{
        address:string,
        name?:string,
    }
}
export interface MailMessege{
    sender?:MailRecipient,
    replyTo?:Array<MailRecipient>,
    toRecipients?:Array<MailRecipient>,
    ccRecipients?:Array<MailRecipient>,
    bccRecipients?:Array<MailRecipient>,
    from?:MailRecipient,
    subject:string,
    body:{
        contentType:"text"|"html",
        content:string,
    },
    attachments?:Array<Attachments>
}

interface Attachments{
    "@odata.type": "#microsoft.graph.fileAttachment",
    name:string,
    contentBytes:string,
}

export class msGrapthAPI{
    private clientSecretCredential:ClientSecretCredential;
    private authProvider:AuthenticationProvider;
    private client:Client;

    constructor(tenantId: string, clientId: string, clientSecret: string){
        this.clientSecretCredential = new ClientSecretCredential(tenantId,clientId,clientSecret);
        this.authProvider = {
            "getAccessToken": async (options?:AuthenticationProviderOptions)=>{
                return (await this.clientSecretCredential.getToken(options?.scopes||".default")).token;
            }
        }
        this.client = Client.initWithMiddleware({"authProvider":this.authProvider})
    }
    public async getGroupMembers(groupID:string):Promise<GroupMembers|undefined>{
        const apiURL = `/groups/${groupID}/members`;
        try{
            const apiResponse = await this.client.api(apiURL).get();
            return apiResponse as GroupMembers;
        }catch(e){
            console.error("getGroupMembers Error:");
            console.error(e);
            return;
        }
    }

    public async getUsers(){
        const apiURL = "/users";
        try{
            const apiResponse = await this.client.api(apiURL).get();
            return apiResponse;
        }catch(e){
            console.error("getUsers Error:")
            console.error(e);
        }
    }

    public async sendMail(messege:MailMessege){
        const apiURL = messege.sender?`/users/${messege.sender.emailAddress.address}/sendMail`:`/me/sendMail`;
        delete messege.sender;
        try{
            const apiRsponse = await this.client.api(apiURL).post({"message":messege,"saveToSentItems":true});
            return apiRsponse;
        }catch(e){
            console.error("sendMail Error:")
            console.error(e);
            return null;
        }
    }
}
```
</details>