001/*
002 * Copyright (c) 2009 The openGion Project.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
013 * either express or implied. See the License for the specific language
014 * governing permissions and limitations under the License.
015 */
016package org.opengion.fukurou.mail;
017
018import org.opengion.fukurou.util.FileUtil;
019import org.opengion.fukurou.util.UnicodeCorrecter;                                      // 5.9.3.3 (2015/12/26) package を、mail → util に移動のため
020import org.opengion.fukurou.system.OgRuntimeException ;                         // 6.4.2.0 (2016/01/29)
021
022import static org.opengion.fukurou.system.HybsConst.CR;                         // 6.1.0.0 (2014/12/26) refactoring
023import static org.opengion.fukurou.system.HybsConst.BUFFER_MIDDLE;      // 6.1.0.0 (2014/12/26) refactoring
024
025import java.io.IOException;
026import java.io.UnsupportedEncodingException;
027import java.io.File;
028import java.io.PrintWriter;
029import java.util.Date;
030import java.util.Enumeration;
031import java.util.Map;
032import java.util.LinkedHashMap;
033import java.util.Collections;                                                                           // 6.4.3.1 (2016/02/12) refactoring
034
035import jakarta.mail.Header;
036import jakarta.mail.Part;
037import jakarta.mail.BodyPart;
038import jakarta.mail.Multipart;
039import jakarta.mail.Message;
040import jakarta.mail.MessagingException;
041import jakarta.mail.Flags;
042import jakarta.mail.internet.MimeMessage;
043import jakarta.mail.internet.MimeUtility;
044import jakarta.mail.internet.InternetAddress;
045
046/**
047 * MailMessage は、受信メールを処理するためのラッパークラスです。
048 *
049 * メッセージオブジェクトを引数にとるコンストラクタによりオブジェクトが作成されます。
050 * 日本語処置などを簡易的に扱えるように、ラッパクラス的な使用方法を想定しています。
051 * 必要であれば(例えば、添付ファイルを取り出すために、MailAttachFiles を利用する場合など)
052 * 内部のメッセージオブジェクトを取り出すことが可能です。
053 * MailReceiveListener クラスの receive( MailMessage ) メソッドで、メールごとにイベントが
054 * 発生して、処理する形態が一般的です。
055 *
056 * @version  4.0
057 * @author   Kazuhiko Hasegawa
058 * @since    JDK5.0,
059 */
060public class MailMessage {
061
062        private static final String MSG_EX = "メッセージ情報のハンドリングに失敗しました。" ;
063
064        private final String  host ;
065        private final String  user ;
066        private final Message message ;
067        /** 6.4.3.1 (2016/02/12) Collections.synchronizedMap で同期処理を行います。  */
068        private final Map<String,String> headerMap ;
069
070        private String subject   ;
071        private String content   ;
072        private String messageID ;
073
074        /**
075         * メッセージオブジェクトを指定して構築します。
076         *
077         * @og.rev 6.4.3.1 (2016/02/12) Collections.synchronizedMap で同期処理を行います。
078         *
079         * @param message メッセージオブジェクト
080         * @param host ホスト
081         * @param user ユーザー
082         */
083        public MailMessage( final Message message,final String host,final String user ) {
084                this.host = host;
085                this.user = user;
086                this.message = message;
087                headerMap    = makeHeaderMap( null );                   // 6.4.3.1 (2016/02/12)
088        }
089
090        /**
091         * 内部の メッセージオブジェクトを返します。
092         *
093         * @return メッセージオブジェクト
094         */
095        public Message getMessage() {
096                return message;
097        }
098
099        /**
100         * 内部の ホスト名を返します。
101         *
102         * @return      ホスト名
103         */
104        public String getHost() {
105                return host;
106        }
107
108        /**
109         * 内部の ユーザー名を返します。
110         *
111         * @return      ユーザー名
112         */
113        public String getUser() {
114                return user;
115        }
116
117        /**
118         * メールのヘッダー情報を文字列に変換して返します。
119         * キーは、ヘッダー情報の取り出しと同一です。
120         * 例) Return-Path,Delivered-To,Date,From,To,Cc,Subject,Content-Type,Message-Id
121         *
122         * @param       key メールのヘッダーキー
123         *
124         * @return      キーに対するメールのヘッダー情報
125         */
126        public String getHeader( final String key ) {
127                return headerMap.get( key );
128        }
129
130        /**
131         * メールの指定のヘッダー情報を文字列に変換して返します。
132         * ヘッダー情報の取り出しキーと同一の項目を リターンコードで結合しています。
133         * Return-Path,Delivered-To,Date,From,To,Cc,Subject,Content-Type,Message-Id
134         *
135         * @return      メールの指定のヘッダー情報
136         * @og.rtnNotNull
137         */
138        public String getHeaders() {
139                final String[] keys = headerMap.keySet().toArray( new String[headerMap.size()] );
140                final StringBuilder buf = new StringBuilder( BUFFER_MIDDLE );
141                for( int i=0; i<keys.length; i++ ) {
142                        buf.append( keys[i] ).append(':').append( headerMap.get( keys[i] ) ).append( CR );              // 6.0.2.5 (2014/10/31) char を append する。
143                }
144                return buf.toString();
145        }
146
147        /**
148         * メールのタイトル(Subject)を返します。
149         * 日本語文字コード処理も行っています。(JIS→unicode変換等)
150         *
151         * @og.rev 4.3.3.5 (2008/11/08) 日本語MIMEエンコードされた文字列を mimeDecode でデコードします。
152         *
153         * @return      メールのタイトル
154         */
155        public String getSubject() {
156                if( subject == null ) {
157                        try {
158                                subject = mimeDecode( message.getSubject() );
159                        }
160                        catch( final MessagingException ex ) {
161                                // メッセージ情報のハンドリングに失敗しました。
162                                throw new OgRuntimeException( MSG_EX,ex );
163                        }
164                }
165                if( subject == null ) { subject = "No Subject" ;}
166                return subject;
167        }
168
169        /**
170         * メールの本文(Content)を返します。
171         * 日本語文字コード処理も行っています。(JIS→unicode変換等)
172         *
173         * @return      メールの本文
174         */
175        public String getContent() {
176                if( content == null ) {
177                        content = UnicodeCorrecter.correctToCP932( mime2str( message ) );
178                }
179                return content;
180        }
181
182        /**
183         * メッセージID を取得します。
184         *
185         * 基本的には、メッセージIDをそのまま(前後の &gt;, &lt;)は取り除きます。
186         * メッセージIDのないメールは、"unknown." + SentData + "." + From という文字列を
187         * 作成します。
188         * さらに、送信日やFrom がない場合、または、文字列として取り出せない場合、
189         * "unknown" を返します。
190         *
191         * @og.rev 4.3.3.5 (2008/11/08) 送信時刻がNULLの場合の処理を追加
192         *
193         * @return メッセージID
194         */
195        public String getMessageID() {
196                if( messageID == null ) {
197                        try {
198                                messageID = ((MimeMessage)message).getMessageID();
199                                // 6.4.1.1 (2016/01/16) PMD refactoring. Avoid if (x != y) ..; else ..;
200                                if( messageID == null ) {
201                                        // 4.3.3.5 (2008/11/08) SentDate が null のケースがあるため。
202                                        Date dt = message.getSentDate();
203                                        if( dt == null ) { dt = message.getReceivedDate(); }
204                                        final Long date = (dt == null) ? 0L : dt.getTime();
205                                        final String from = ((InternetAddress[])message.getFrom())[0].getAddress() ;
206                                        messageID = "unknown." + date + "." + from ;
207                                }
208                                else {
209                                        messageID = messageID.substring(1,messageID.length()-1) ;
210                                }
211                        }
212                        catch( final MessagingException ex ) {
213                                // メッセージ情報のハンドリングに失敗しました。
214                                throw new OgRuntimeException( MSG_EX,ex );
215                        }
216                }
217                return messageID ;
218        }
219
220        /**
221         * メッセージをメールサーバーから削除するかどうかをセットします。
222         *
223         * @param       flag    削除するかどうか [true:行う/false:行わない]
224         */
225        public void deleteMessage( final boolean flag ) {
226                try {
227                        message.setFlag(Flags.Flag.DELETED, flag);
228                }
229                catch( final MessagingException ex ) {
230                        // メッセージ情報のハンドリングに失敗しました。
231                        throw new OgRuntimeException( MSG_EX,ex );
232                }
233        }
234
235        /**
236         * メールの内容を文字列として表現します。
237         * デバッグや、簡易的なメールの内容の取り出し、エラー時のメール保存に使用します。
238         *
239         * @return      メールの内容の文字列表現
240         * @og.rtnNotNull
241         */
242        public String getSimpleMessage() {
243                final StringBuilder buf = new StringBuilder( BUFFER_MIDDLE )
244                        .append( getHeaders() ).append( CR )
245                        .append( "Subject:" ).append( getSubject() ).append( CR )
246                        .append( "===============================" ).append( CR )
247                        .append( getContent() ).append( CR )
248                        .append( "===============================" ).append( CR );
249
250                return buf.toString();
251        }
252
253        /**
254         * メールの内容と、あれば添付ファイルを指定のフォルダにセーブします。
255         * saveMessage( dir )と、saveAttachFiles( dir,true ) を同時に呼び出しています。
256         *
257         * @param       dir     メールと添付ファイルをセーブするフォルダ
258         */
259        public void saveSimpleMessage( final String dir ) {
260
261                saveMessage( dir );
262
263                saveAttachFiles( dir,true );
264        }
265
266        /**
267         * メールの内容を文字列として指定のフォルダにセーブします。
268         * メッセージID.txt という本文にセーブします。
269         * デバッグや、簡易的なメールの内容の取り出し、エラー時のメール保存に使用します。
270         *
271         * @param       dir     メールの内容をセーブするフォルダ
272         */
273        public void saveMessage( final String dir ) {
274
275                final String msgId = getMessageID() ;
276
277                // 3.8.0.0 (2005/06/07) FileUtil#getPrintWriter を利用。
278                final File file = new File( dir,msgId + ".txt" );
279                final PrintWriter writer = FileUtil.getPrintWriter( file,"UTF-8" );
280                writer.println( getSimpleMessage() );
281
282                writer.close();
283        }
284
285        /**
286         * メールの添付ファイルが存在する場合に、指定のフォルダにセーブします。
287         *
288         * 添付ファイルが存在する場合のみ、処理を実行します。
289         * useMsgId にtrue を設定すると、メッセージID というフォルダを作成し、その下に、
290         * 連番 + "_" + 添付ファイル名 でセーブします。(メールには同一ファイル名を複数添付できる為)
291         * false の場合は、指定のディレクトリ直下に、連番 + "_" + 添付ファイル名 でセーブします。
292         *
293         * @og.rev 4.3.3.5 (2008/11/08) ディレクトリ指定時のセパレータのチェックを追加
294         *
295         * @param       dir     添付ファイルをセーブするフォルダ
296         * @param       useMsgId        メッセージIDフォルダを作成してセーブ場合:true
297         *          指定のディレクトリ直下にセーブする場合:false
298         */
299        public void saveAttachFiles( final String dir,final boolean useMsgId ) {
300
301                final String attDirStr ;
302                if( useMsgId ) {
303                        final String msgId = getMessageID() ;
304                        // 4.3.3.5 (2008/11/08) ディレクトリ指定時のセパレータのチェックを追加
305                        if( dir.endsWith( "/" ) ) {
306                                attDirStr = dir + msgId + "/";
307                        }
308                        else {
309                                attDirStr = dir + "/" + msgId + "/";
310                        }
311                }
312                else {
313                        attDirStr = dir ;
314                }
315
316                final MailAttachFiles attFiles = new MailAttachFiles( message );
317                final String[] files = attFiles.getNames();
318                if( files.length > 0 ) {
319        //              String attDirStr = dir + "/" + msgId + "/";
320        //              File attDir = new File( attDirStr );
321        //              if( !attDir.exists() ) {
322        //                      if( ! attDir.mkdirs() ) {
323        //                      final String errMsg = "添付ファイルのディレクトリの作成に失敗しました。[" + attDirStr + "]";
324        //                              throw new OgRuntimeException( errMsg );
325        //                      }
326        //              }
327
328                        // 添付ファイル名を指定しないと、番号 + "_" + 添付ファイル名になる。
329                        for( int i=0; i<files.length; i++ ) {
330                                attFiles.saveFileName( attDirStr,null,i );
331                        }
332                }
333        }
334
335        /**
336         * 受領確認がセットされている場合の 返信先アドレスを返します。
337         * セットされていない場合は、null を返します。
338         * 受領確認は、Disposition-Notification-To ヘッダにセットされる事とし、
339         * このヘッダの内容を返します。セットされていなければ、null を返します。
340         *
341         * @return 返信先アドレス(Disposition-Notification-To ヘッダの内容)
342         */
343        public String getNotificationTo() {
344                return headerMap.get( "Disposition-Notification-To" );
345        }
346
347        /**
348         * ヘッダー情報を持った、Enumeration から、ヘッダーと値のペアの文字列を作成します。
349         *
350         * ヘッダー情報は、Message#getAllHeaders() か、Message#getMatchingHeaders( String[] )
351         * で得られる Enumeration に、Header オブジェクトとして取得できます。
352         * このヘッダーオブジェクトから、キー(getName()) と値(getValue()) を取り出します。
353         * 結果は、キー:値 の文字列として、リターンコードで区切ります。
354         *
355         * @og.rev 4.3.3.5 (2008/11/08) 日本語MIMEエンコードされた文字列を mimeDecode でデコードします。
356         * @og.rev 6.4.3.1 (2016/02/12) Collections.synchronizedMap で同期処理を行います。
357         *
358         * @param headerList ヘッダー情報配列
359         *
360         * @return ヘッダー情報の キー:値 のMap
361         */
362        private Map<String,String> makeHeaderMap( final String[] headerList ) {
363                final Map<String,String> headMap = Collections.synchronizedMap( new LinkedHashMap<>() );
364                try {
365                        final Enumeration<?> enume;             // 4.3.3.6 (2008/11/15) Generics警告対応
366                        if( headerList == null ) {
367                                enume = message.getAllHeaders();
368                        }
369                        else {
370                                enume = message.getMatchingHeaders( headerList );
371                        }
372
373                        while( enume.hasMoreElements() ) {
374                                final Header header = (Header)enume.nextElement();
375                                final String name  = header.getName();
376                                // 4.3.3.5 (2008/11/08) 日本語MIMEエンコードされた文字列を mimeDecode でデコードします。
377                                // 6.4.9.1 (2016/08/05) refactoring
378                                final String val = headMap.get( name );
379                                // 4.3.3.5 (2008/11/08) 日本語MIMEエンコードされた文字列を mimeDecode でデコードします。
380                                final String value = ( val == null ) ? mimeDecode( header.getValue() )
381                                                                                                         : val + "," + mimeDecode( header.getValue() );
382
383                                headMap.put( name,value );
384                        }
385                }
386                catch( final MessagingException ex2 ) {
387                        // メッセージ情報のハンドリングに失敗しました。
388                        throw new OgRuntimeException( MSG_EX,ex2 );
389                }
390
391                return headMap;
392        }
393
394        /**
395         * Part オブジェクトから、最初に見つけた text/plain を取り出します。
396         *
397         * Part は、マルチパートというPartに複数のPartを持っていたり、さらにその中にも
398         * Part を持っているような構造をしています。
399         * ここでは、最初に見つけた、MimeType が、text/plain の場合に、文字列に
400         * 変換して、返しています。それ以外の場合、再帰的に、text/plain が
401         * 見つかるまで、処理を続けます。
402         * また、特別に、HN0256 からのトラブルメールは、Content-Type が、text/plain のみに
403         * なっている為 CONTENTS が、JIS のまま、取り出されてしまうため、強制的に
404         * Content-Type を、"text/plain; charset=iso-2022-jp" に変更しています。
405         *
406         * @param       part Part最大取り込み件数
407         *
408         * @return 最初の text/plain 文字列。見つからない場合は、null を返します。
409         * @throws MessagingException jakarta.mail 関連のエラーが発生したとき
410         * @throws IOException 入出力エラーが発生したとき
411         */
412        private String mime2str( final Part part ) {
413                String content = null;
414
415                try {
416                        if( part.isMimeType("text/plain") ) {
417                                // HN0256 からのトラブルメールは、Content-Type が、text/plain のみになっている為
418                                // CONTENTS が、JIS のまま、取り出されてしまう。強制的に変更しています。
419                                if( "text/plain".equalsIgnoreCase( part.getContentType() ) ) {
420                                        final MimeMessage msg = new MimeMessage( (MimeMessage)part );
421                                        msg.setHeader( "Content-Type","text/plain; charset=iso-2022-jp" );
422                                        content = (String)msg.getContent();
423                                }
424                                else {
425                                        content = (String)part.getContent();
426                                }
427                        }
428                        else if( part.isMimeType("message/rfc822") ) {          // Nested Message
429                                content = mime2str( (Part)part.getContent() );
430                        }
431                        else if( part.isMimeType("multipart/*") ) {
432                                final Multipart mp = (Multipart)part.getContent();
433
434                                final int count = mp.getCount();
435                                for( int i=0; i<count; i++ ) {
436                                        final BodyPart bp = mp.getBodyPart(i);
437                                        content = mime2str( bp );
438                                        if( content != null ) { break; }
439                                }
440                        }
441                }
442                catch( final MessagingException ex ) {
443                        // メッセージ情報のハンドリングに失敗しました。
444                        throw new OgRuntimeException( MSG_EX,ex );
445                }
446                catch( final IOException ex2 ) {
447                        final String errMsg = "テキスト情報の取り出しに失敗しました。" ;
448                        throw new OgRuntimeException( errMsg,ex2 );
449                }
450
451                return content ;
452        }
453
454        /**
455         * エンコードされた文字列を、デコードします。
456         *
457         * MIMEエンコード は、 =? で開始するエンコード文字列 ですが、場合によって、前のスペースが
458         * 存在しない場合があります。
459         * また、メーラーによっては、エンコード文字列を ダブルコーテーションでくくる処理が入っている
460         * 場合もあります。
461         * これらの一連のエンコード文字列をデコードします。
462         *
463         * @og.rev 4.3.3.5 (2008/11/08) 日本語MIMEエンコードされた文字列をデコードします。
464         *
465         * @param       text    エンコードされた文字列(されていない場合は、そのまま返します)
466         *
467         * @return      デコードされた文字列
468         */
469        public static final String mimeDecode( final String text ) {
470                if( text == null || text.indexOf( "=?" ) < 0 ) { return text; }
471
472                String rtnText = text.replace( '\t',' ' );              // 若干トリッキーな処理
473                try {
474                        // encode-word の =? の前にはスペースが必要。
475                        // ここでは、分割して、デコード処理を行うことで、対応
476                        int pos1 = rtnText.indexOf( "=?" );                     // デコードの開始
477                        int pos2 = 0;                                                           // デコードの終了
478                        final StringBuilder buf = new StringBuilder( BUFFER_MIDDLE )
479                                .append( rtnText.substring( 0,pos1 ) );
480                        while( pos1 >= 0 ) {
481                                pos2 = rtnText.indexOf( "?=",pos1 ) + 2;                // デコードの終了
482                                final String sub = rtnText.substring( pos1,pos2 );
483                                buf.append( UnicodeCorrecter.correctToCP932( MimeUtility.decodeText( sub ) ) );
484                                pos1 = rtnText.indexOf( "=?",pos2 );                    // デコードの開始
485                                if( pos1 > 0 ) {
486                                        buf.append( rtnText.substring( pos2,pos1 ) );
487                                }
488                        }
489                        buf.append( rtnText.substring( pos2 ) );
490                        rtnText = buf.toString() ;
491                }
492                catch( final UnsupportedEncodingException ex ) {
493                        final String errMsg = "テキスト情報のデコードに失敗しました。" ;
494                        throw new OgRuntimeException( errMsg,ex );
495                }
496                return rtnText;
497        }
498}