├── .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 |
--------------------------------------------------------------------------------