├── .github ├── dependabot.yml └── workflows │ ├── dependabot.yml │ └── verify.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json ├── config.inc.php.dist ├── custom_from.js ├── custom_from.php ├── images ├── custom_from_off.png └── custom_from_on.png ├── localization ├── de_DE.inc ├── en_US.inc └── fr_FR.inc ├── skins ├── classic │ └── custom_from.css ├── elastic │ └── custom_from.css └── larry │ └── custom_from.css └── tests ├── CustomFromTest.php └── rcmail_mock.php /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | # See: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions 2 | 3 | name: Dependabot automation 4 | on: pull_request 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | jobs: 11 | dependabot: 12 | runs-on: ubuntu-latest 13 | if: github.event.pull_request.user.login == 'dependabot[bot]' 14 | steps: 15 | - name: Approve Dependabot PR 16 | run: gh pr review --approve "$PR_URL" 17 | env: 18 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 19 | PR_URL: ${{github.event.pull_request.html_url}} 20 | - name: Enable auto-merge for Dependabot PR 21 | run: gh pr merge --auto --squash "$PR_URL" 22 | env: 23 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 24 | PR_URL: ${{github.event.pull_request.html_url}} 25 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: Verify 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | schedule: 11 | - cron: '20 4 * * *' 12 | 13 | jobs: 14 | lint: 15 | name: Run PHP linter validation 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v5 20 | - uses: firehed/lint-php-action@v1 21 | 22 | test: 23 | name: Test with PHPUnit 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v5 28 | - uses: php-actions/composer@v6 29 | - uses: php-actions/phpunit@v4 30 | with: 31 | args: tests 32 | 33 | validate: 34 | name: Validate 35 | runs-on: ubuntu-latest 36 | 37 | needs: 38 | - lint 39 | - test 40 | 41 | steps: 42 | - run: true 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /config.inc.php 3 | /vendor/ 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Custom-From LICENSE file 2 | ======================== 3 | 4 | Copyright (c) 2011 Remi Caput, http://remi.caput.fr/ 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining 7 | a copy of this software and associated documentation files (the 8 | "Software"), to deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, merge, publish, 10 | distribute, sublicense, and/or sell copies of the Software, and to 11 | permit persons to whom the Software is furnished to do so, subject to 12 | the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 21 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 22 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 23 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom-From README file 2 | 3 | ## Overview 4 | 5 | This plugin adds a blue button to the compose screen, next to the identities 6 | selection dropdown. By clicking it, a textbox will replace the dropdown, 7 | allowing you to enter whatever you want as sender value (it must be a valid 8 | "From:" header field value, though). 9 | 10 | When replying to an e-mail sent to you through an address not in your 11 | identities list, plugin will automatically fire and set "From:" header to the 12 | address the original e-mail was sent to. 13 | 14 | ## Install 15 | 16 | ### Option 1: install with Composer 17 | 18 | Execute `composer require r3c/custom-from` from your RoundCube install 19 | directory and run the install command. See instructions from RoundCube website 20 | for details: https://plugins.roundcube.net/. 21 | 22 | ### Option 2: install manually 23 | 24 | Clone repository content to a `custom_from` directory inside your RoundCube 25 | `plugins` directory, so that file `custom_from.php` file can be found at 26 | `$ROUNDCUBE_INSTALL_DIRECTORY/plugins/custom_from/custom_from.php`. 27 | 28 | cd $ROUNDCUBE_INSTALL_DIRECTORY 29 | git clone https://github.com/r3c/custom_from.git 30 | 31 | Then reference plugin by adding an item "custom_from" to RoundCube plugins list 32 | in configuration (variable `$config['plugins']` variable in file 33 | `$ROUNDCUBE_INSTALL_DIRECTORY/config/main.inc.php`). Ensure your web user has 34 | read access to the plugin directory and all files in it. 35 | 36 | ## Usage 37 | 38 | Once plugin is installed, custom sender button will appear at the right 39 | hand side of the identity selection list. 40 | 41 | Open "Reply address (Custom From)" in user preferences to configure how plugin 42 | should behave when replying to an e-mail. 43 | 44 | ## Thanks 45 | 46 | - dwurf (https://github.com/dwurf) for the globals $IMAP and $USER fix 47 | - Peter Dey (https://github.com/peterdey) for the custom header feature 48 | - kermit-the-frog (https://github.com/kermit-the-frog) for various bugfixes 49 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "r3c/custom_from", 3 | "version": "1.8.2", 4 | "description": "Allow manual sender address input ('From' header) when composing", 5 | "type": "roundcube-plugin", 6 | "keywords": [ 7 | "custom", 8 | "from", 9 | "sender", 10 | "address", 11 | "header", 12 | "compose" 13 | ], 14 | "homepage": "https://github.com/r3c/custom_from", 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Rémi Caput", 19 | "homepage": "https://remi.caput.fr", 20 | "role": "Developer" 21 | } 22 | ], 23 | "repositories": [ 24 | { 25 | "type": "composer", 26 | "url": "https://plugins.roundcube.net" 27 | } 28 | ], 29 | "require": { 30 | "php": ">=5.3.0", 31 | "roundcube/plugin-installer": ">=0.1.3" 32 | }, 33 | "require-dev": { 34 | "phpunit/phpunit": "^11.3" 35 | }, 36 | "config": { 37 | "allow-plugins": { 38 | "roundcube/plugin-installer": true 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /config.inc.php.dist: -------------------------------------------------------------------------------- 1 | ') 15 | .attr('title', textEnableHint) 16 | .text(textEnable); 17 | 18 | // Plugin state 19 | var disabled = true; 20 | var senderSelect = []; 21 | var senderTextbox; 22 | 23 | // Feature toggle handler 24 | var toggle = function (event, value) { 25 | if (senderSelect.length < 1) { 26 | return; 27 | } 28 | 29 | if (disabled) { 30 | button 31 | .addClass('custom-from-off') 32 | .removeClass('custom-from-on') 33 | .attr('title', textDisableHint) 34 | .text(textDisable); 35 | 36 | senderTextbox = $('') 37 | .attr('onchange', senderSelect.attr('onchange')) 38 | .attr('value', value || senderSelect.find('option:selected')[0].text); 39 | 40 | senderSelect 41 | .before(senderTextbox) 42 | .removeAttr('name') 43 | .css('display', 'none'); 44 | } else { 45 | button 46 | .addClass('custom-from-on') 47 | .removeClass('custom-from-off') 48 | .attr('title', textEnableHint) 49 | .text(textEnable); 50 | 51 | senderTextbox.remove(); 52 | 53 | senderSelect 54 | .attr('name', '_from') 55 | .css('display', 'inline'); 56 | } 57 | 58 | disabled = !disabled; 59 | }; 60 | 61 | // Toggle plugin on button click 62 | button.bind('click', toggle); 63 | 64 | // Enable plugin on Roundcube initialization 65 | rcmail.addEventListener('init', function (event) { 66 | senderSelect = $('select#_from'); 67 | senderSelect.after($('').html(button)) 68 | }); 69 | 70 | // Make toggle function visible from global scope 71 | return toggle; 72 | })(); 73 | } 74 | -------------------------------------------------------------------------------- /custom_from.php: -------------------------------------------------------------------------------- 1 | add_texts('localization', true); 38 | $this->add_hook('identity_select', array($this, 'identity_select')); 39 | $this->add_hook('message_compose', array($this, 'message_compose')); 40 | $this->add_hook('message_compose_body', array($this, 'message_compose_body')); 41 | $this->add_hook('preferences_list', array($this, 'preferences_list')); 42 | $this->add_hook('preferences_save', array($this, 'preferences_save')); 43 | $this->add_hook('preferences_sections_list', array($this, 'preferences_sections_list')); 44 | $this->add_hook('render_page', array($this, 'render_page')); 45 | $this->add_hook('storage_init', array($this, 'storage_init')); 46 | 47 | $this->load_config(); 48 | 49 | $rcmail = rcmail::get_instance(); 50 | 51 | list($contains, $identity, $rules) = self::get_configuration($rcmail); 52 | 53 | $this->contains = $contains; 54 | $this->identity = $identity; 55 | $this->rules = $rules; 56 | } 57 | 58 | /** 59 | * Override selected identity according to configuration. 60 | */ 61 | public function identity_select($params) 62 | { 63 | $compose_id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GET); 64 | 65 | list($identity) = self::get_state($compose_id); 66 | 67 | // Set selected identity if one was matched 68 | if ($identity !== null) { 69 | foreach ($params['identities'] as $index => $candidate) { 70 | if ($candidate['identity_id'] === $identity) { 71 | $params['selected'] = $index; 72 | 73 | break; 74 | } 75 | } 76 | } 77 | 78 | return $params; 79 | } 80 | 81 | /** 82 | ** Enable custom "From:" field if mail being composed has been sent to an 83 | ** address that looks like a virtual one (i.e. not in user identities list). 84 | */ 85 | public function message_compose($params) 86 | { 87 | // Search for message ID in known parameters 88 | $message_uid_keys = array('draft_uid', 'forward_ui', 'reply_uid', 'uid'); 89 | $message_uid = null; 90 | 91 | foreach ($message_uid_keys as $key) { 92 | if (isset($params['param'][$key])) { 93 | $message_uid = $params['param'][$key]; 94 | 95 | break; 96 | } 97 | } 98 | 99 | // Early return if current message is unknown 100 | $rcmail = rcmail::get_instance(); 101 | $storage = $rcmail->get_storage(); 102 | $headers = $message_uid !== null ? $storage->get_message($message_uid) : null; 103 | 104 | if ($headers === null) { 105 | return; 106 | } 107 | 108 | // Browse headers where addresses will be fetched from 109 | $recipients = array(); 110 | $rules = isset($params['param']['draft_uid']) ? array_intersect_key($this->rules, array('from' => null)) : $this->rules; 111 | 112 | foreach ($rules as $header => $rule) { 113 | $header_value = $headers->get($header); 114 | $addresses = $header_value !== null ? rcube_mime::decode_address_list($header_value, null, false) : array(); 115 | 116 | // Decode recipients and matching rules from retrieved addresses 117 | foreach ($addresses as $address) { 118 | if (!isset($address['mailto'])) { 119 | continue; 120 | } 121 | 122 | $email = $address['mailto']; 123 | 124 | if (strpos($email, $this->contains) === false) { 125 | continue; 126 | } 127 | 128 | $recipients[] = array( 129 | 'domain' => preg_replace('/^[^@]*@(.*)$/', '$1', $email), 130 | 'email' => $email, 131 | 'email_prefix' => preg_replace('/^([^@+]*)\\+[^@]+@(.*)$/', '$1@$2', $email), 132 | 'match_always' => strpos($rule, 'o') !== false, 133 | 'match_domain' => strpos($rule, 'd') !== false, 134 | 'match_exact' => strpos($rule, 'e') !== false, 135 | 'match_prefix' => strpos($rule, 'p') !== false, 136 | 'name' => $address['name'], 137 | ); 138 | } 139 | } 140 | 141 | // Build lookup maps from domain name and full address 142 | $identity_by_domain = array(); 143 | $identity_by_email = array(); 144 | $identity_default = null; 145 | 146 | foreach ($rcmail->user->list_identities() as $identity) { 147 | $domain = strtolower(preg_replace('/^[^@]*@(.*)$/', '$1', $identity['email'])); 148 | $email = strtolower($identity['email']); 149 | $match = array( 150 | 'id' => $identity['identity_id'], 151 | 'name' => $identity['name'], 152 | 'rank' => $identity['standard'] === '1' ? 1 : 0 153 | ); 154 | 155 | if (!isset($identity_by_domain[$domain]) || $identity_by_domain[$domain]['rank'] < $match['rank']) 156 | $identity_by_domain[$domain] = $match; 157 | 158 | if (!isset($identity_by_email[$email]) || $identity_by_email[$email]['rank'] < $match['rank']) 159 | $identity_by_email[$email] = $match; 160 | 161 | if ($identity_default === null || $identity_default['rank'] < $match['rank']) 162 | $identity_default = $match; 163 | } 164 | 165 | // Find best possible match from recipients and identities 166 | $best_match = null; 167 | $best_score = 4; 168 | 169 | foreach ($recipients as $recipient) { 170 | $domain = strtolower($recipient['domain']); 171 | $email = strtolower($recipient['email']); 172 | $email_prefix = strtolower($recipient['email_prefix']); 173 | 174 | // Relevance score 0: match by e-mail found in identities 175 | if ($recipient['match_exact'] && isset($identity_by_email[$email])) { 176 | $identity = $identity_by_email[$email]; 177 | $score = 0; 178 | } 179 | 180 | // Relevance score 1: match by e-mail found in identities after removing "+suffix" 181 | else if ($recipient['match_prefix'] && isset($identity_by_email[$email_prefix])) { 182 | $identity = $identity_by_email[$email_prefix]; 183 | $score = 1; 184 | } 185 | 186 | // Relevance score 2: match by domain found in identities 187 | else if ($recipient['match_domain'] && isset($identity_by_domain[$domain])) { 188 | $identity = $identity_by_domain[$domain]; 189 | $score = 2; 190 | } 191 | 192 | // Relevance score 3: any match found 193 | else if ($recipient['match_always'] && $identity_default !== null) { 194 | $identity = $identity_default; 195 | $score = 3; 196 | } 197 | 198 | // No match 199 | else 200 | continue; 201 | 202 | // Overwrite best match if score is better (lower) 203 | if ($score < $best_score) { 204 | $best_match = array( 205 | 'email' => $recipient['email'], 206 | 'identity' => $identity, 207 | 'name' => $recipient['name'] 208 | ); 209 | 210 | $best_score = $score; 211 | } 212 | } 213 | 214 | // Define name and identity to be used for composing 215 | if ($best_match === null) { 216 | // No match, preserve default behavior 217 | $identity = null; 218 | $sender = null; 219 | } else if ($best_score === 0) { 220 | // Exact match, select it and preserve identity selector 221 | $identity = $best_match['identity']['id']; 222 | $sender = null; 223 | } else if ($this->identity !== self::PREFERENCE_COMPOSE_IDENTITY_EXACT) { 224 | // Approximate match + use identity, select it and set custom sender with identity name 225 | $identity = $best_match['identity']['id']; 226 | $sender = format_email_recipient($best_match['email'], $best_match['identity']['name']); 227 | } else { 228 | // Approximate match + identity shouldn't be used, set custom sender with matched name 229 | $identity = null; 230 | $sender = format_email_recipient($best_match['email'], $best_match['name']); 231 | } 232 | 233 | // Store matched address 234 | $compose_id = $params['id']; 235 | 236 | self::set_state($compose_id, $identity, $sender); 237 | } 238 | 239 | public function message_compose_body($params) 240 | { 241 | global $MESSAGE; 242 | 243 | $compose_id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GET); 244 | $message = isset($params['message']) ? $params['message'] : (isset($MESSAGE) ? $MESSAGE : null); 245 | 246 | // Log error and exit in case required state variables are undefined to avoid unwanted behavior 247 | if ($compose_id === null) { 248 | self::emit_error('missing \'_id\' GET parameter, custom_from won\'t work properly'); 249 | 250 | return; 251 | } else if ($message === null) { 252 | self::emit_error('missing $message hook parameter and global variable, custom_from won\'t work properly'); 253 | 254 | return; 255 | } 256 | 257 | list($identity, $sender) = self::get_state($compose_id); 258 | 259 | if ($sender !== null) { 260 | // Disable signature when a sender override was defined but no 261 | // identity should be reused 262 | if ($identity === null) { 263 | $rcmail = rcmail::get_instance(); 264 | $rcmail->output->set_env('show_sig', false); 265 | } 266 | 267 | // Remove selected virtual e-mail from message headers so it doesn't 268 | // get copied to "cc" field, see details at 269 | // https://github.com/r3c/custom_from/issues/19. This implementation 270 | // relies on how method `compose_header_value` from 271 | // `rcmail_sendmail.php` is currently reading headers to build "cc" 272 | // field value and is most probably not a good use of 273 | // `message_compose_body` hook but there is currently no better 274 | // place to introduce a dedicated hook, see follow-up at 275 | // https://github.com/roundcube/roundcubemail/issues/7590. 276 | foreach (array_keys($this->rules) as $header) { 277 | $header_value = $message->headers->get($header); 278 | 279 | if ($header_value !== null) { 280 | $addresses_header = rcube_mime::decode_address_list($header_value, null, false); 281 | 282 | $addresses_filtered = array_filter($addresses_header, function ($candidate) use ($sender) { 283 | return $candidate['mailto'] !== $sender; 284 | }); 285 | 286 | $addresses_string = array_map(function ($address) { 287 | return $address['string']; 288 | }, $addresses_filtered); 289 | 290 | $message->headers->set($header, implode(', ', $addresses_string)); 291 | } 292 | } 293 | } 294 | } 295 | 296 | public function preferences_list($params) 297 | { 298 | if ($params['section'] !== self::PREFERENCE_SECTION) { 299 | return $params; 300 | } 301 | 302 | // Read configuration in case it was just changed 303 | $rcmail = rcmail::get_instance(); 304 | 305 | list($contains, $identity, $rules) = self::get_configuration($rcmail); 306 | 307 | // Contains preference 308 | $compose_contains = new html_inputfield(array('id' => self::PREFERENCE_COMPOSE_CONTAINS, 'name' => self::PREFERENCE_COMPOSE_CONTAINS)); 309 | 310 | // Identity preference 311 | $compose_identity = new html_select(array('id' => self::PREFERENCE_COMPOSE_IDENTITY, 'name' => self::PREFERENCE_COMPOSE_IDENTITY)); 312 | $compose_identity->add(self::get_text($rcmail, 'preference_compose_identity_loose'), self::PREFERENCE_COMPOSE_IDENTITY_LOOSE); 313 | $compose_identity->add(self::get_text($rcmail, 'preference_compose_identity_exact'), self::PREFERENCE_COMPOSE_IDENTITY_EXACT); 314 | 315 | // Subject preference, using global configuration as fallback value 316 | $rule = isset($rules['to']) ? $rules['to'] : ''; 317 | 318 | if (strpos($rule, 'o') !== false) 319 | $compose_subject_value = self::PREFERENCE_COMPOSE_SUBJECT_ALWAYS; 320 | else if (strpos($rule, 'd') !== false) 321 | $compose_subject_value = self::PREFERENCE_COMPOSE_SUBJECT_DOMAIN; 322 | else if (strpos($rule, 'p') !== false) 323 | $compose_subject_value = self::PREFERENCE_COMPOSE_SUBJECT_PREFIX; 324 | else if (strpos($rule, 'e') !== false) 325 | $compose_subject_value = self::PREFERENCE_COMPOSE_SUBJECT_EXACT; 326 | else 327 | $compose_subject_value = self::PREFERENCE_COMPOSE_SUBJECT_NEVER; 328 | 329 | $compose_subject = new html_select(array('id' => self::PREFERENCE_COMPOSE_SUBJECT, 'name' => self::PREFERENCE_COMPOSE_SUBJECT)); 330 | $compose_subject->add(self::get_text($rcmail, 'preference_compose_subject_never'), self::PREFERENCE_COMPOSE_SUBJECT_NEVER); 331 | $compose_subject->add(self::get_text($rcmail, 'preference_compose_subject_exact'), self::PREFERENCE_COMPOSE_SUBJECT_EXACT); 332 | $compose_subject->add(self::get_text($rcmail, 'preference_compose_subject_prefix'), self::PREFERENCE_COMPOSE_SUBJECT_PREFIX); 333 | $compose_subject->add(self::get_text($rcmail, 'preference_compose_subject_domain'), self::PREFERENCE_COMPOSE_SUBJECT_DOMAIN); 334 | $compose_subject->add(self::get_text($rcmail, 'preference_compose_subject_always'), self::PREFERENCE_COMPOSE_SUBJECT_ALWAYS); 335 | 336 | $params['blocks'] = array( 337 | 'compose' => array( 338 | 'name' => self::get_text_quoted($rcmail, 'preference_compose'), 339 | 'options' => array( 340 | array( 341 | 'title' => html::label(self::PREFERENCE_COMPOSE_SUBJECT, self::get_text_quoted($rcmail, 'preference_compose_subject')), 342 | 'content' => $compose_subject->show(array($compose_subject_value)) 343 | ), 344 | array( 345 | 'title' => html::label(self::PREFERENCE_COMPOSE_CONTAINS, self::get_text_quoted($rcmail, 'preference_compose_contains')), 346 | 'content' => $compose_contains->show($contains) 347 | ), 348 | array( 349 | 'title' => html::label(self::PREFERENCE_COMPOSE_IDENTITY, self::get_text_quoted($rcmail, 'preference_compose_identity')), 350 | 'content' => $compose_identity->show(array($identity)) 351 | ) 352 | ) 353 | ) 354 | ); 355 | 356 | return $params; 357 | } 358 | 359 | public function preferences_save($params) 360 | { 361 | if ($params['section'] === self::PREFERENCE_SECTION) { 362 | $keys = array( 363 | self::PREFERENCE_COMPOSE_CONTAINS, 364 | self::PREFERENCE_COMPOSE_IDENTITY, 365 | self::PREFERENCE_COMPOSE_SUBJECT 366 | ); 367 | 368 | foreach ($keys as $key) { 369 | $params['prefs'][$key] = rcube_utils::get_input_value($key, rcube_utils::INPUT_POST); 370 | } 371 | } 372 | 373 | return $params; 374 | } 375 | 376 | public function preferences_sections_list($params) 377 | { 378 | $rcmail = rcmail::get_instance(); 379 | 380 | if (!$rcmail->config->get('custom_from_preference_disable', false)) { 381 | $params['list'][self::PREFERENCE_SECTION] = array( 382 | 'id' => self::PREFERENCE_SECTION, 383 | 'section' => self::get_text($rcmail, 'preference') 384 | ); 385 | } 386 | 387 | return $params; 388 | } 389 | 390 | public function render_page($params) 391 | { 392 | $template = $params['template']; 393 | 394 | if ($template === 'compose') { 395 | $compose_id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GET); 396 | 397 | list(, $sender) = self::get_state($compose_id); 398 | 399 | if ($sender !== null) { 400 | $rcmail = rcmail::get_instance(); 401 | $rcmail->output->add_footer(''); 402 | } 403 | 404 | $this->include_script('custom_from.js'); 405 | } 406 | 407 | if ($template === 'compose' || $template === 'settings') { 408 | $this->include_stylesheet($this->local_skin_path() . '/custom_from.css'); 409 | } 410 | 411 | return $params; 412 | } 413 | 414 | /** 415 | ** Adds additional headers to supported headers list. 416 | */ 417 | public function storage_init($params) 418 | { 419 | $fetch_headers = isset($params['fetch_headers']) ? $params['fetch_headers'] : ''; 420 | $separator = $fetch_headers !== '' ? ' ' : ''; 421 | 422 | foreach (array_keys($this->rules) as $header) { 423 | $fetch_headers .= $separator . $header; 424 | $separator = ' '; 425 | } 426 | 427 | $params['fetch_headers'] = $fetch_headers; 428 | 429 | return $params; 430 | } 431 | 432 | private static function emit_error($message) 433 | { 434 | rcube::raise_error(array('code' => 500, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => $message), true, false); 435 | } 436 | 437 | private static function get_configuration(rcmail $rcmail) 438 | { 439 | // Early return with no rule if plugin "auto enable" mode is disabled 440 | if (!$rcmail->config->get('custom_from_compose_auto', true)) { 441 | return array('', self::PREFERENCE_COMPOSE_IDENTITY_EXACT, array()); 442 | } 443 | 444 | $use_preference = !$rcmail->config->get('custom_from_preference_disable', false); 445 | 446 | // Read "contains" parameter from global configuration & preferences if allowed 447 | $contains = $rcmail->config->get(self::PREFERENCE_COMPOSE_CONTAINS, ''); 448 | 449 | if ($use_preference) { 450 | $contains = self::get_preference($rcmail, self::PREFERENCE_COMPOSE_CONTAINS, $contains); 451 | } 452 | 453 | // Read "identity" parameter from global configuration & preferences if allowed 454 | $identity = $rcmail->config->get('custom_from_compose_identity', self::PREFERENCE_COMPOSE_IDENTITY_LOOSE); 455 | 456 | if ($use_preference) { 457 | $identity = self::get_preference($rcmail, self::PREFERENCE_COMPOSE_IDENTITY, $identity); 458 | } 459 | 460 | // Read "rules" parameter from global configuration & preferences if allowed 461 | $rules_config = $rcmail->config->get('custom_from_header_rules', 'bcc=ep;cc=ep;from=ep;to=ep;x-original-to=ep'); 462 | $rules = array(); 463 | 464 | foreach (explode(';', $rules_config) as $pair) { 465 | $fields = explode('=', $pair, 2); 466 | 467 | if (count($fields) === 2) { 468 | $rules[strtolower(trim($fields[0]))] = strtolower(trim($fields[1])); 469 | } 470 | } 471 | 472 | if ($use_preference) { 473 | $subject = self::get_preference($rcmail, self::PREFERENCE_COMPOSE_SUBJECT, ''); 474 | $subject_rules = array( 475 | self::PREFERENCE_COMPOSE_SUBJECT_ALWAYS => 'deop', 476 | self::PREFERENCE_COMPOSE_SUBJECT_DOMAIN => 'dep', 477 | self::PREFERENCE_COMPOSE_SUBJECT_EXACT => 'e', 478 | self::PREFERENCE_COMPOSE_SUBJECT_NEVER => '', 479 | self::PREFERENCE_COMPOSE_SUBJECT_PREFIX => 'ep' 480 | ); 481 | 482 | if (isset($subject_rules[$subject])) { 483 | $rule = $subject_rules[$subject]; 484 | 485 | foreach (array('bcc', 'cc', 'from', 'to', 'x-original-to') as $header) { 486 | if ($rule !== '') 487 | $rules[$header] = $rule; 488 | else 489 | unset($rules[$header]); 490 | } 491 | } 492 | } 493 | 494 | return array($contains, $identity, $rules); 495 | } 496 | 497 | private static function get_preference(rcmail $rcmail, string $key, string $default) 498 | { 499 | return isset($rcmail->user->prefs[$key]) ? $rcmail->user->prefs[$key] : $default; 500 | } 501 | 502 | private static function get_state($compose_id) 503 | { 504 | return $compose_id !== null && isset($_SESSION['custom_from_' . $compose_id]) 505 | ? $_SESSION['custom_from_' . $compose_id] 506 | : array(null, null); 507 | } 508 | 509 | private static function get_text(rcmail $rcmail, string $key) 510 | { 511 | return $rcmail->gettext($key, 'custom_from'); 512 | } 513 | 514 | private static function get_text_quoted(rcmail $rcmail, string $key) 515 | { 516 | return rcmail::Q(self::get_text($rcmail, $key)); 517 | } 518 | 519 | private static function set_state($compose_id, $identity, $sender) 520 | { 521 | $_SESSION['custom_from_' . $compose_id] = array($identity, $sender); 522 | } 523 | } 524 | -------------------------------------------------------------------------------- /images/custom_from_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r3c/custom_from/a78bc864523e3a0dc3ea4afe6f5f9e085b7aac85/images/custom_from_off.png -------------------------------------------------------------------------------- /images/custom_from_on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r3c/custom_from/a78bc864523e3a0dc3ea4afe6f5f9e085b7aac85/images/custom_from_on.png -------------------------------------------------------------------------------- /localization/de_DE.inc: -------------------------------------------------------------------------------- 1 | td.section::before { 22 | content: "\f21b"; 23 | } -------------------------------------------------------------------------------- /skins/elastic/custom_from.css: -------------------------------------------------------------------------------- 1 | #compose_from .custom-from-off, 2 | #compose_from .custom-from-on { 3 | background-position: 8px 8px; 4 | background-repeat: no-repeat; 5 | padding: 0 16px; 6 | text-indent: -9999px; 7 | } 8 | 9 | #compose_from .custom-from-off { 10 | background-image: url('../../images/custom_from_off.png'); 11 | } 12 | 13 | #compose_from .custom-from-on { 14 | background-image: url('../../images/custom_from_on.png'); 15 | } 16 | 17 | .listing.iconized tr.custom_from>td.section::before { 18 | content: "\f21b"; 19 | } -------------------------------------------------------------------------------- /skins/larry/custom_from.css: -------------------------------------------------------------------------------- 1 | #composeheaders .editfield .custom-from-off, 2 | #composeheaders .editfield .custom-from-on { 3 | background-position: 0 0; 4 | } 5 | 6 | #composeheaders .editfield .custom-from-off { 7 | background-image: url('../../images/custom_from_off.png'); 8 | } 9 | 10 | #composeheaders .editfield .custom-from-on { 11 | background-image: url('../../images/custom_from_on.png'); 12 | } 13 | 14 | #composeheaders input.custom_from { 15 | min-width: 300px; 16 | width: auto; 17 | } 18 | 19 | .listing.iconized tr.custom_from>td.section::before { 20 | content: "\f21b"; 21 | } -------------------------------------------------------------------------------- /tests/CustomFromTest.php: -------------------------------------------------------------------------------- 1 | mock_config(array()); 35 | $rcmail->mock_user(array(), array()); 36 | 37 | $plugin = self::create_plugin(); 38 | 39 | rcube_utils::mock_input_value('_id', '42'); 40 | 41 | self::set_state($plugin, '42', $identity, null); 42 | 43 | $params = $plugin->identity_select(array('identities' => array( 44 | array('identity_id' => '1'), 45 | array('identity_id' => '2') 46 | ))); 47 | 48 | if ($expected !== null) { 49 | $this->assertSame($params['selected'], $expected); 50 | } else { 51 | $this->assertSame(isset($params['selected']), false); 52 | } 53 | } 54 | 55 | public static function storage_init_should_fetch_headers_provider(): array 56 | { 57 | return array( 58 | array( 59 | array('custom_from_compose_auto' => false), 60 | array(), 61 | '' 62 | ), 63 | array( 64 | array('custom_from_compose_auto' => true), 65 | array(), 66 | 'bcc cc from to x-original-to' 67 | ), 68 | array( 69 | array(self::RULES => 'header1=a;header2=b'), 70 | array(), 71 | 'header1 header2' 72 | ), 73 | array( 74 | array(self::RULES => 'header=a', self::DISABLE => true), 75 | array(self::SUBJECT => 'always'), 76 | 'header' 77 | ), 78 | array( 79 | array(self::RULES => 'header=a', self::DISABLE => false), 80 | array(self::SUBJECT => 'always'), 81 | 'header bcc cc from to x-original-to' 82 | ) 83 | ); 84 | } 85 | 86 | #[DataProvider('storage_init_should_fetch_headers_provider')] 87 | public function test_storage_init_should_fetch_headers($config_values, $user_prefs, $expected): void 88 | { 89 | $rcmail = rcmail::mock(); 90 | $rcmail->mock_config($config_values); 91 | $rcmail->mock_user(array(), $user_prefs); 92 | 93 | $plugin = self::create_plugin(); 94 | $params = $plugin->storage_init(array()); 95 | 96 | $this->assertSame($params['fetch_headers'], $expected); 97 | } 98 | 99 | public static function message_compose_should_set_state_provider(): array 100 | { 101 | return array( 102 | // Missing message shouldn't match anything 103 | array( 104 | 'unknown', 105 | array('to' => 'alice@primary.ext'), 106 | array(self::RULES => 'to=o'), 107 | array(), 108 | null, 109 | null 110 | ), 111 | // Subject rule "exact" should match address exactly 112 | array( 113 | 'uid', 114 | array('to' => 'alice@primary.ext'), 115 | array(self::RULES => 'to=e'), 116 | array(), 117 | '1', 118 | null 119 | ), 120 | // Subject rule "exact" shouldn't match suffix 121 | array( 122 | 'uid', 123 | array('to' => 'alice+suffix@primary.ext'), 124 | array(self::RULES => 'to=e'), 125 | array(), 126 | null, 127 | null, 128 | ), 129 | // Subject rule "prefix" should match address exactly 130 | array( 131 | 'uid', 132 | array('to' => 'alice@primary.ext'), 133 | array(), 134 | array(), 135 | '1', 136 | null 137 | ), 138 | // Subject rule "prefix" should match address by prefix 139 | array( 140 | 'uid', 141 | array('to' => 'alice+suffix@primary.ext'), 142 | array(), 143 | array(), 144 | '1', 145 | 'Alice ' 146 | ), 147 | // Subject rule "prefix" should not match different user 148 | array( 149 | 'uid', 150 | array('to' => 'carl+suffix@primary.ext'), 151 | array(), 152 | array(), 153 | null, 154 | null, 155 | ), 156 | // Subject rule "domain" on custom header should match address by domain 157 | array( 158 | 'uid', 159 | array('to' => 'unknown@primary.ext', 'x-custom' => 'unknown@secondary.ext'), 160 | array(self::RULES => 'x-custom=d'), 161 | array(), 162 | '3', 163 | 'Carl ' 164 | ), 165 | // Subject rule "domain" should not match different domain 166 | array( 167 | 'uid', 168 | array('to' => 'unknown@unknown.ext'), 169 | array(self::RULES => 'to=d'), 170 | array(), 171 | null, 172 | null, 173 | ), 174 | // Subject rule "other" should match anything 175 | array( 176 | 'uid', 177 | array('to' => 'unknown@unknown.ext'), 178 | array(self::RULES => 'to=o'), 179 | array(), 180 | '2', 181 | 'Bob ' 182 | ), 183 | // Subject rule is overridden by user prefrences 184 | array( 185 | 'uid', 186 | array('to' => 'unknown@secondary.ext'), 187 | array(self::RULES => 'to=e'), 188 | array(self::SUBJECT => 'domain'), 189 | '3', 190 | 'Carl ' 191 | ), 192 | // Contains constraint in configuration options matches address 193 | array( 194 | 'uid', 195 | array('to' => 'alice+match@primary.ext'), 196 | array(self::CONTAINS => 'match'), 197 | array(self::SUBJECT => 'domain'), 198 | '1', 199 | 'Alice ' 200 | ), 201 | // Contains constraint in configuration options rejects no match 202 | array( 203 | 'uid', 204 | array('to' => 'alice+other@primary.ext'), 205 | array(self::CONTAINS => 'match'), 206 | array(self::SUBJECT => 'domain'), 207 | null, 208 | null 209 | ), 210 | // Contains constraint in user preferences rejects no match 211 | array( 212 | 'uid', 213 | array('to' => 'alice+other@primary.ext'), 214 | array(self::CONTAINS => 'other'), 215 | array(self::CONTAINS => 'match', self::SUBJECT => 'always'), 216 | null, 217 | null 218 | ), 219 | // Identity behavior "default" returns matched identity with no sender on exact match 220 | array( 221 | 'uid', 222 | array('to' => 'carl@secondary.ext'), 223 | array(), 224 | array(self::SUBJECT => 'always'), 225 | '3', 226 | null 227 | ), 228 | // Identity behavior "default" returns matched identity and sender with identity name on domain match 229 | array( 230 | 'uid', 231 | array('to' => 'unknown@secondary.ext'), 232 | array(), 233 | array(self::SUBJECT => 'domain'), 234 | '3', 235 | 'Carl ' 236 | ), 237 | // Identity behavior "loose" returns matched identity and sender with identity name on prefix match 238 | array( 239 | 'uid', 240 | array('to' => 'SomeName '), 241 | array(), 242 | array(self::IDENTITY => 'loose', self::SUBJECT => 'prefix'), 243 | '3', 244 | 'Carl ' 245 | ), 246 | // Identity behavior "exact" returns matched identity with no sender on exact match 247 | array( 248 | 'uid', 249 | array('to' => 'carl@secondary.ext'), 250 | array(self::IDENTITY => 'exact'), 251 | array(self::SUBJECT => 'always'), 252 | '3', 253 | null 254 | ), 255 | // Identity behavior "exact" returns no identity and sender with recipient name on prefix match 256 | array( 257 | 'uid', 258 | array('to' => 'SomeName '), 259 | array(), 260 | array(self::IDENTITY => 'exact', self::SUBJECT => 'prefix'), 261 | null, 262 | 'SomeName ' 263 | ), 264 | // Header "from" should be selected when composing a draft 265 | array( 266 | 'draft_uid', 267 | array('from' => 'other@primary.ext', 'to' => 'alice@primary.ext'), 268 | array(self::RULES => 'from=d;to=e'), 269 | array(), 270 | '2', 271 | 'Bob ' 272 | ) 273 | ); 274 | } 275 | 276 | #[DataProvider('message_compose_should_set_state_provider')] 277 | public function test_message_compose_should_set_state($uid_key, $message, $config_values, $user_prefs, $expected_identity, $expected_sender): void 278 | { 279 | $identity1 = array('identity_id' => '1', 'email' => 'alice@primary.ext', 'name' => 'Alice', 'standard' => '0'); 280 | $identity2 = array('identity_id' => '2', 'email' => 'bob@primary.ext', 'name' => 'Bob', 'standard' => '1'); 281 | $identity3 = array('identity_id' => '3', 'email' => 'carl@secondary.ext', 'name' => 'Carl', 'standard' => '0'); 282 | 283 | $compose_id = '17'; 284 | $message_id = '42'; 285 | $rcmail = rcmail::mock(); 286 | $rcmail->mock_config($config_values); 287 | $rcmail->mock_message($message_id, $message); 288 | $rcmail->mock_user(array($identity1, $identity2, $identity3), $user_prefs); 289 | 290 | $plugin = self::create_plugin(); 291 | $plugin->message_compose(array('id' => $compose_id, 'param' => array($uid_key => $message_id))); 292 | 293 | $state = self::get_state($plugin, $compose_id); 294 | 295 | $this->assertSame($state, array($expected_identity, $expected_sender)); 296 | } 297 | 298 | private static function create_plugin() 299 | { 300 | $plugin = new custom_from(null); 301 | $plugin->init(); 302 | 303 | return $plugin; 304 | } 305 | 306 | private static function get_state($plugin, $compose_id) 307 | { 308 | $class = new ReflectionClass($plugin); 309 | $method = $class->getMethod('get_state'); 310 | 311 | return $method->invokeArgs(null, array($compose_id)); 312 | } 313 | 314 | private static function set_state($plugin, $compose_id, $identity, $sender) 315 | { 316 | $class = new ReflectionClass($plugin); 317 | $method = $class->getMethod('set_state'); 318 | 319 | return $method->invokeArgs(null, array($compose_id, $identity, $sender)); 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /tests/rcmail_mock.php: -------------------------------------------------------------------------------- 1 | '; 6 | } 7 | 8 | class rcmail 9 | { 10 | private static rcmail $instance; 11 | 12 | public static function get_instance() 13 | { 14 | return self::$instance; 15 | } 16 | 17 | public static function mock() 18 | { 19 | self::$instance = new self(); 20 | 21 | return self::$instance; 22 | } 23 | 24 | public $config = null; 25 | public $messages = array(); 26 | public $user = null; 27 | 28 | public function get_message($id) 29 | { 30 | return $this->messages[$id]; 31 | } 32 | 33 | public function get_storage() 34 | { 35 | return $this; 36 | } 37 | 38 | public function mock_config($config_values) 39 | { 40 | $this->config = new rcube_config($config_values); 41 | } 42 | 43 | public function mock_message($id, $message_fields) 44 | { 45 | $this->messages[$id] = new rcube_message($message_fields); 46 | } 47 | 48 | public function mock_user($identities, $prefs) 49 | { 50 | $this->user = new rcube_user($identities, $prefs); 51 | } 52 | } 53 | 54 | class rcube_config 55 | { 56 | private array $values; 57 | 58 | public function __construct($values) 59 | { 60 | $this->values = $values; 61 | } 62 | 63 | public function get($name, $def = null) 64 | { 65 | return isset($this->values[$name]) ? $this->values[$name] : $def; 66 | } 67 | } 68 | 69 | class rcube_message 70 | { 71 | public array $fields; 72 | 73 | public function __construct($fields) 74 | { 75 | $this->fields = $fields; 76 | } 77 | 78 | public function get($name) 79 | { 80 | return isset($this->fields[$name]) ? $this->fields[$name] : null; 81 | } 82 | } 83 | 84 | class rcube_mime 85 | { 86 | public static function decode_address_list($input) 87 | { 88 | return preg_match('/(.*) <(.*)>/', $input, $match) === 1 89 | ? array(array('mailto' => $match[2], 'name' => $match[1])) 90 | : array(array('mailto' => $input, 'name' => $input)); 91 | } 92 | } 93 | 94 | class rcube_plugin 95 | { 96 | public function add_texts() {} 97 | public function add_hook() {} 98 | public function load_config() {} 99 | } 100 | 101 | class rcube_user 102 | { 103 | public array $prefs; 104 | 105 | private array $identities; 106 | 107 | public function __construct($identities, $prefs) 108 | { 109 | $this->identities = $identities; 110 | $this->prefs = $prefs; 111 | } 112 | 113 | public function list_identities() 114 | { 115 | return $this->identities; 116 | } 117 | } 118 | 119 | class rcube_utils 120 | { 121 | public const INPUT_GET = 1; 122 | 123 | private static $input_values = array(); 124 | 125 | public static function get_input_value($name, $mode) 126 | { 127 | return $mode === self::INPUT_GET ? self::$input_values[$name] : null; 128 | } 129 | 130 | public static function mock_input_value($name, $value) 131 | { 132 | self::$input_values[$name] = $value; 133 | } 134 | } 135 | --------------------------------------------------------------------------------