(覚書)メールフォーム最後まで

CodeIgniterとSmarty懐かしい環境でのメールフォームの改修
```php
<?php
/*
==============================================
ファイル完結システム（確認→登録→ダウンロード）
==============================================
*/

// Contact Controller（送信処理追加版）
class Contact extends CI_Controller {
    
    public function __construct() {
        parent::__construct();
        $this->load->database();
        $this->load->helper(['form', 'url', 'file', 'download']);
        $this->load->library(['form_validation', 'upload', 'session', 'smarty', 'zip']);
        $this->load->model('Contact_model');
    }
    
    /**
     * メイン処理
     */
    public function index() {
        $data = [
            'errors' => [],
            'form_data' => [],
            'success' => false
        ];
        
        if ($this->input->method() === 'post') {
            $action = $this->input->post('action');
            
            switch ($action) {
                case 'confirm':
                    $data = $this->_process_confirm();
                    if (empty($data['errors'])) {
                        $this->_show_confirm($data);
                        return;
                    }
                    break;
                    
                case 'submit':
                    $data = $this->_process_submit();
                    if ($data['success']) {
                        $this->_show_complete($data);
                        return;
                    }
                    break;
            }
        }
        
        $this->_show_form($data);
    }
    
    /**
     * 確認処理（前回と同じ）
     */
    private function _process_confirm() {
        $data = [
            'errors' => [],
            'form_data' => [],
            'uploaded_files' => [],
            'file_info' => []
        ];
        
        // バリデーション設定
        $this->form_validation->set_rules('name', '名前', 'required|trim|max_length[100]');
        $this->form_validation->set_rules('email', 'メールアドレス', 'required|valid_email|max_length[255]');
        $this->form_validation->set_rules('message', 'メッセージ', 'required|trim|max_length[1000]');
        
        if (!$this->form_validation->run()) {
            $data['errors'] = $this->form_validation->error_array();
        }
        
        $data['form_data'] = [
            'name' => trim($this->input->post('name')),
            'email' => trim($this->input->post('email')),
            'message' => trim($this->input->post('message'))
        ];
        
        $upload_result = $this->_handle_file_upload_temp();
        
        if (!empty($upload_result['errors'])) {
            $data['errors'] = array_merge($data['errors'], $upload_result['errors']);
        } else {
            $data['uploaded_files'] = $upload_result['files'];
            $data['file_info'] = $upload_result['file_info'];
            
            $this->session->set_userdata('temp_uploaded_files', $upload_result['files']);
            $this->session->set_userdata('temp_file_info', $upload_result['file_info']);
        }
        
        return $data;
    }
    
    /**
     * 送信処理（日時フォルダに整理＆DB登録）
     */
    private function _process_submit() {
        $data = ['success' => false, 'errors' => [], 'submission_id' => '', 'contact_id' => 0];
        
        // 送信日時でフォルダ作成
        $submission_id = date('YmdHis') . '_' . uniqid(); // 例：20241209143022_abc123
        $user_name = $this->input->post('name');
        $safe_user_name = $this->_sanitize_filename($user_name);
        
        // フォルダパス生成: uploads/2024/12/09/田中太郎_20241209143022_abc123/
        $final_folder = './uploads/' . date('Y/m/d') . '/' . $safe_user_name . '_' . $submission_id . '/';
        
        // フォルダ作成
        if (!$this->_create_directory($final_folder)) {
            $data['errors']['folder'] = 'フォルダ作成に失敗しました。';
            return $data;
        }
        
        // セッションから一時ファイル情報を取得
        $temp_files = $this->session->userdata('temp_uploaded_files') ?: [];
        $file_info = $this->session->userdata('temp_file_info') ?: [];
        
        // ファイルを最終フォルダに移動
        $moved_files = $this->_move_files_to_final_folder($temp_files, $final_folder);
        
        // お問い合わせ情報テキストファイル作成
        $this->_create_contact_info_file($final_folder, [
            'name' => $this->input->post('name'),
            'email' => $this->input->post('email'),
            'message' => $this->input->post('message'),
            'submission_id' => $submission_id,
            'created_at' => date('Y-m-d H:i:s'),
            'file_info' => $file_info
        ]);
        
        // データベース保存
        $form_data = [
            'name' => $this->input->post('name'),
            'email' => $this->input->post('email'),
            'message' => $this->input->post('message'),
            'submission_id' => $submission_id,
            'folder_path' => $final_folder,
            'uploaded_files' => json_encode($moved_files),
            'file_info' => json_encode($file_info)
        ];
        
        $contact_id = $this->Contact_model->insert_contact($form_data);
        
        if ($contact_id) {
            $data['success'] = true;
            $data['submission_id'] = $submission_id;
            $data['contact_id'] = $contact_id;
            $data['folder_path'] = $final_folder;
            $data['file_count'] = count($moved_files);
            
            // セッションクリア
            $this->session->unset_userdata(['temp_uploaded_files', 'temp_file_info']);
            
            // 一時フォルダのクリーンアップ
            $this->_cleanup_temp_files();
            
        } else {
            $data['errors']['db'] = 'データベースエラーが発生しました。';
        }
        
        return $data;
    }
    
    /**
     * ディレクトリ作成
     */
    private function _create_directory($path) {
        if (!is_dir($path)) {
            return mkdir($path, 0755, true);
        }
        return true;
    }
    
    /**
     * ファイルを最終フォルダに移動
     */
    private function _move_files_to_final_folder($temp_files, $final_folder) {
        $moved_files = [];
        
        foreach ($temp_files as $temp_file) {
            $temp_path = './uploads/temp/' . $temp_file;
            $final_path = $final_folder . $temp_file;
            
            if (file_exists($temp_path)) {
                if (rename($temp_path, $final_path)) {
                    $moved_files[] = $temp_file;
                } else {
                    log_message('error', 'ファイル移動失敗: ' . $temp_path . ' -> ' . $final_path);
                }
            }
        }
        
        return $moved_files;
    }
    
    /**
     * お問い合わせ情報テキストファイル作成
     */
    private function _create_contact_info_file($folder_path, $contact_data) {
        $info_text = "=====================================\n";
        $info_text .= "お問い合わせ情報\n";
        $info_text .= "=====================================\n\n";
        $info_text .= "送信日時: {$contact_data['created_at']}\n";
        $info_text .= "送信ID: {$contact_data['submission_id']}\n";
        $info_text .= "お名前: {$contact_data['name']}\n";
        $info_text .= "メールアドレス: {$contact_data['email']}\n\n";
        $info_text .= "お問い合わせ内容:\n";
        $info_text .= "-------------------------------------\n";
        $info_text .= $contact_data['message'] . "\n\n";
        
        if (!empty($contact_data['file_info'])) {
            $info_text .= "添付ファイル:\n";
            $info_text .= "-------------------------------------\n";
            foreach ($contact_data['file_info'] as $i => $file) {
                $info_text .= ($i + 1) . ". {$file['input_display_name']}\n";
                $info_text .= "   元ファイル名: {$file['original_name']}\n";
                $info_text .= "   保存ファイル名: {$file['saved_name']}\n";
                $info_text .= "   サイズ: " . number_format($file['file_size']) . " bytes\n";
                $info_text .= "   形式: {$file['file_type']}\n";
                $info_text .= "   アップロード: {$file['upload_time']}\n\n";
            }
        }
        
        file_put_contents($folder_path . 'お問い合わせ内容.txt', $info_text);
    }
    
    /**
     * ファイル名用の文字列サニタイズ
     */
    private function _sanitize_filename($name) {
        $name = trim($name);
        $name = preg_replace('/[^\p{L}\p{N}\s]/u', '', $name);
        $name = preg_replace('/\s+/', '_', $name);
        
        if (mb_strlen($name) > 20) {
            $name = mb_substr($name, 0, 20);
        }
        
        if (empty($name)) {
            $name = 'user_' . date('mdHis');
        }
        
        return $name;
    }
    
    /**
     * 一時ファイルのクリーンアップ
     */
    private function _cleanup_temp_files() {
        $temp_path = './uploads/temp/';
        
        if (is_dir($temp_path)) {
            $files = glob($temp_path . '*');
            $now = time();
            
            foreach ($files as $file) {
                if (is_file($file) && ($now - filemtime($file)) > 3600) { // 1時間
                    unlink($file);
                }
            }
        }
    }
    
    /**
     * ファイルアップロード処理（一時保存）
     */
    private function _handle_file_upload_temp() {
        $result = [
            'files' => [],
            'file_info' => [],
            'errors' => []
        ];
        
        $user_name = trim($this->input->post('name'));
        $safe_user_name = $this->_sanitize_filename($user_name);
        
        $file_inputs = [
            'application_form' => '申請書',
            'resume' => '履歴書', 
            'portfolio' => 'ポートフォリオ',
            'certificate' => '証明書',
            'other_document' => 'その他資料'
        ];
        
        if (isset($_FILES['upload_file']) && !empty($_FILES['upload_file']['name'])) {
            $file_inputs['upload_file'] = '添付ファイル';
        }
        
        foreach ($file_inputs as $input_name => $display_name) {
            if (!isset($_FILES[$input_name]) || empty($_FILES[$input_name]['name'])) {
                continue;
            }
            
            $temp_path = './uploads/temp/';
            if (!is_dir($temp_path)) {
                mkdir($temp_path, 0755, true);
            }
            
            $original_name = $_FILES[$input_name]['name'];
            $file_size = $_FILES[$input_name]['size'];
            $file_type = $_FILES[$input_name]['type'];
            $extension = pathinfo($original_name, PATHINFO_EXTENSION);
            
            $timestamp = date('YmdHis');
            $safe_filename = "{$safe_user_name}_{$display_name}_{$timestamp}." . strtolower($extension);
            
            $config = [
                'upload_path' => $temp_path,
                'allowed_types' => 'jpg|jpeg|png|gif|pdf|doc|docx|xls|xlsx|txt|zip|csv',
                'max_size' => 10240,
                'max_width' => 3000,
                'max_height' => 3000,
                'file_name' => $safe_filename,
                'remove_spaces' => TRUE
            ];
            
            $this->upload->initialize($config);
            
            if ($this->upload->do_upload($input_name)) {
                $upload_data = $this->upload->data();
                
                $result['files'][] = $upload_data['file_name'];
                $result['file_info'][] = [
                    'original_name' => $original_name,
                    'saved_name' => $upload_data['file_name'],
                    'input_name' => $input_name,
                    'input_display_name' => $display_name,
                    'file_size' => $file_size,
                    'file_type' => $file_type,
                    'extension' => $extension,
                    'upload_time' => date('Y-m-d H:i:s'),
                    'user_name' => $user_name
                ];
                
            } else {
                $result['errors'][$input_name] = strip_tags($this->upload->display_errors());
            }
        }
        
        return $result;
    }
    
    /**
     * フォーム表示
     */
    private function _show_form($data) {
        $this->smarty->assign('errors', $data['errors']);
        $this->smarty->assign('form_data', $data['form_data']);
        $this->smarty->display('contact/form.tpl');
    }
    
    /**
     * 確認画面表示
     */
    private function _show_confirm($data) {
        $this->smarty->assign('form_data', $data['form_data']);
        $this->smarty->assign('uploaded_files', $data['uploaded_files']);
        $this->smarty->assign('file_info', $data['file_info']);
        $this->smarty->display('contact/confirm.tpl');
    }
    
    /**
     * 完了画面表示
     */
    private function _show_complete($data) {
        $this->smarty->assign('submission_id', $data['submission_id']);
        $this->smarty->assign('contact_id', $data['contact_id']);
        $this->smarty->assign('file_count', $data['file_count']);
        $this->smarty->display('contact/complete.tpl');
    }
}

/*
==============================================
管理画面Controller（Admin.php）
==============================================
*/
class Admin extends CI_Controller {
    
    public function __construct() {
        parent::__construct();
        $this->load->database();
        $this->load->helper(['url', 'download', 'file']);
        $this->load->library(['pagination', 'smarty', 'zip']);
        $this->load->model('Contact_model');
        
        // 管理者認証チェック（必要に応じて）
        // $this->_check_admin_auth();
    }
    
    /**
     * 投稿一覧表示
     */
    public function contact_list() {
        // ページング設定
        $config = [
            'base_url' => site_url('admin/contact_list'),
            'total_rows' => $this->Contact_model->count_contacts(),
            'per_page' => 20,
            'uri_segment' => 3,
            'use_page_numbers' => TRUE,
            
            'full_tag_open' => '<div class="pagination">',
            'full_tag_close' => '</div>',
            'first_link' => '最初',
            'last_link' => '最後',
            'next_link' => '次へ',
            'prev_link' => '前へ',
            'cur_tag_open' => '<span class="current">',
            'cur_tag_close' => '</span>',
        ];
        
        $this->pagination->initialize($config);
        
        $page = ($this->uri->segment(3)) ? $this->uri->segment(3) : 1;
        $offset = ($page - 1) * $config['per_page'];
        
        $contacts = $this->Contact_model->get_all_contacts_with_files($config['per_page'], $offset);
        
        // 各投稿のファイル情報を整理
        foreach ($contacts as &$contact) {
            $contact['file_count'] = 0;
            $contact['total_size'] = 0;
            $contact['has_files'] = false;
            
            if (!empty($contact['file_info_array'])) {
                $contact['file_count'] = count($contact['file_info_array']);
                $contact['has_files'] = true;
                
                foreach ($contact['file_info_array'] as $file) {
                    $contact['total_size'] += $file['file_size'];
                }
            }
            
            $contact['zip_available'] = $this->_check_files_exist($contact);
        }
        
        $this->smarty->assign('contacts', $contacts);
        $this->smarty->assign('pagination', $this->pagination->create_links());
        $this->smarty->assign('total_rows', $config['total_rows']);
        $this->smarty->display('admin/contact_list.tpl');
    }
    
    /**
     * ZIPダウンロード
     */
    public function download_zip($contact_id) {
        $contact = $this->Contact_model->get_contact_with_files($contact_id);
        
        if (!$contact) {
            show_404('投稿が見つかりません');
            return;
        }
        
        if (empty($contact['file_info_array'])) {
            $this->session->set_flashdata('error', 'ダウンロードできるファイルがありません。');
            redirect('admin/contact_list');
            return;
        }
        
        // フォルダパス取得
        $folder_path = $contact['folder_path'];
        
        if (!is_dir($folder_path)) {
            $this->session->set_flashdata('error', 'ファイルフォルダが見つかりません。');
            redirect('admin/contact_list');
            return;
        }
        
        // ZIP作成
        $zip_result = $this->_create_contact_zip($contact, $folder_path);
        
        if (!$zip_result['success']) {
            $this->session->set_flashdata('error', $zip_result['error']);
            redirect('admin/contact_list');
            return;
        }
        
        // ダウンロード実行
        $this->_download_and_cleanup($zip_result['zip_path'], $zip_result['zip_name']);
    }
    
    /**
     * 個別ファイルダウンロード
     */
    public function download_file($contact_id, $file_index) {
        $contact = $this->Contact_model->get_contact_with_files($contact_id);
        
        if (!$contact || empty($contact['file_info_array'])) {
            show_404();
            return;
        }
        
        if (!isset($contact['file_info_array'][$file_index])) {
            show_404();
            return;
        }
        
        $file_info = $contact['file_info_array'][$file_index];
        $file_path = $contact['folder_path'] . $file_info['saved_name'];
        
        if (!file_exists($file_path)) {
            show_404('ファイルが見つかりません');
            return;
        }
        
        // 元のファイル名でダウンロード
        force_download($file_info['original_name'], file_get_contents($file_path));
    }
    
    /**
     * 投稿詳細表示
     */
    public function contact_detail($contact_id) {
        $contact = $this->Contact_model->get_contact_with_files($contact_id);
        
        if (!$contact) {
            show_404();
            return;
        }
        
        // ファイル存在チェック
        if (!empty($contact['file_info_array'])) {
            foreach ($contact['file_info_array'] as &$file) {
                $file_path = $contact['folder_path'] . $file['saved_name'];
                $file['file_exists'] = file_exists($file_path);
                
                if ($file['file_exists']) {
                    $file['actual_size'] = filesize($file_path);
                }
            }
        }
        
        $this->smarty->assign('contact', $contact);
        $this->smarty->display('admin/contact_detail.tpl');
    }
    
    /**
     * ファイル存在チェック
     */
    private function _check_files_exist($contact) {
        if (empty($contact['file_info_array']) || empty($contact['folder_path'])) {
            return false;
        }
        
        if (!is_dir($contact['folder_path'])) {
            return false;
        }
        
        foreach ($contact['file_info_array'] as $file) {
            $file_path = $contact['folder_path'] . $file['saved_name'];
            if (file_exists($file_path)) {
                return true;
            }
        }
        
        return false;
    }
    
    /**
     * ZIP作成
     */
    private function _create_contact_zip($contact, $folder_path) {
        $result = ['success' => false, 'error' => '', 'zip_path' => '', 'zip_name' => ''];
        
        try {
            $safe_name = preg_replace('/[^a-zA-Z0-9_-]/', '_', $contact['name']);
            $zip_name = "contact_{$contact['id']}_{$safe_name}_{$contact['submission_id']}.zip";
            $zip_path = "./uploads/temp/{$zip_name}";
            
            $temp_dir = './uploads/temp/';
            if (!is_dir($temp_dir)) {
                mkdir($temp_dir, 0755, true);
            }
            
            $zip = new ZipArchive();
            if ($zip->open($zip_path, ZipArchive::CREATE) !== TRUE) {
                throw new Exception('ZIPファイルの作成に失敗しました。');
            }
            
            // フォルダ内の全ファイルを追加
            $files = glob($folder_path . '*');
            $added_files = 0;
            
            foreach ($files as $file) {
                if (is_file($file)) {
                    $filename = basename($file);
                    $zip->addFile($file, $filename);
                    $added_files++;
                }
            }
            
            $zip->close();
            
            if ($added_files === 0) {
                unlink($zip_path);
                throw new Exception('追加できるファイルがありませんでした。');
            }
            
            $result['success'] = true;
            $result['zip_path'] = $zip_path;
            $result['zip_name'] = $zip_name;
            
        } catch (Exception $e) {
            $result['error'] = $e->getMessage();
            log_message('error', 'ZIP作成エラー: ' . $e->getMessage());
        }
        
        return $result;
    }
    
    /**
     * ダウンロード実行＆クリーンアップ
     */
    private function _download_and_cleanup($zip_path, $zip_name) {
        if (!file_exists($zip_path)) {
            show_error('ZIPファイルが見つかりません。');
            return;
        }
        
        header('Content-Type: application/zip');
        header('Content-Disposition: attachment; filename="' . $zip_name . '"');
        header('Content-Length: ' . filesize($zip_path));
        header('Cache-Control: no-cache, must-revalidate');
        header('Expires: 0');
        
        readfile($zip_path);
        unlink($zip_path);
        exit;
    }
}

/*
==============================================
Model拡張版
==============================================
*/
class Contact_model extends CI_Model {
    
    private $table = 'contact_form';
    
    public function __construct() {
        parent::__construct();
        $this->load->database();
    }
    
    /**
     * お問い合わせデータ挿入（改良版）
     */
    public function insert_contact($data) {
        $insert_data = [
            'name' => $data['name'],
            'email' => $data['email'],
            'message' => $data['message'],
            'submission_id' => $data['submission_id'],
            'folder_path' => $data['folder_path'],
            'uploaded_files' => $data['uploaded_files'],
            'file_info' => $data['file_info'],
            'created_at' => date('Y-m-d H:i:s')
        ];
        
        $this->db->insert($this->table, $insert_data);
        return $this->db->insert_id(); // 挿入されたIDを返す
    }
    
    /**
     * ファイル情報付きで全データ取得
     */
    public function get_all_contacts_with_files($limit = 100, $offset = 0) {
        $query = $this->db
            ->select('*')
            ->order_by('created_at', 'DESC')
            ->limit($limit, $offset)
            ->get($this->table);
        
        $results = $query->result_array();
        
        foreach ($results as &$result) {
            $result['uploaded_files_array'] = !empty($result['uploaded_files']) 
                ? json_decode($result['uploaded_files'], true) 
                : [];
            $result['file_info_array'] = !empty($result['file_info']) 
                ? json_decode($result['file_info'], true) 
                : [];
        }
        
        return $results;
    }
    
    /**
     * ファイル情報付きでデータ取得
     */
    public function get_contact_with_files($id) {
        $query = $this->db->where('id', $id)->get($this->table);
        $result = $query->row_array();
        
        if ($result) {
            $result['uploaded_files_array'] = !empty($result['uploaded_files']) 
                ? json_decode($result['uploaded_files'], true) 
                : [];
            $result['file_info_array'] = !empty($result['file_info']) 
                ? json_decode($result['file_info'], true) 
                : [];
        }
        
        return $result;
    }
    
    /**
     * データ件数取得
     */
    public function count_contacts() {
        return $this->db->count_all($this->table);
    }
}
?>

<!-- 
==============================================
完了画面テンプレート: contact/complete.tpl
============================================== 
-->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>送信完了</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 20px; background-color: #f5f5f5; text-align: center; }
        .container { max-width: 600px; margin: 50px auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
        .success-icon { font-size: 48px; color: #28a745; margin-bottom: 20px; }
        h2 { color: #28a745; margin-bottom: 20px; }
        .success-message { background: #d4edda; color: #155724; padding: 20px; border-radius: 8px; margin: 20px 0; border: 1px solid #c3e6cb; }
        .info-box { background: #e7f3ff; border: 1px solid #b8daff; border-radius: 4px; padding: 15px; margin: 20px 0; text-align: left; }
        .submission-id { font-family: monospace; font-weight: bold; color: #0056b3; }
        .btn { padding: 12px 30px; background: #007bff; color: white; border: none; border-radius: 4px; text-decoration: none; display: inline-block; font-size: 16px; margin: 10px; }
        .btn:hover { background: #0056b3; }
        .btn-secondary { background: #6c757d; }
        .btn-secondary:hover { background: #545b62; }
    </style>
</head>
<body>
    <div class="container">
        <div class="success-icon">✓</div>
        <h2>送信完了</h2>
        
        <div class="success-message">
            <p><strong>お問い合わせを受け付けました。</strong></p>
            <p>ご連絡いただきありがとうございます。<br>
            内容を確認の上、担当者よりご連絡させていただきます。</p>
        </div>
        
        <div class="info-box">
            <h4>📋 送信情報</h4>
```