loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add support for incoming emails (#22056)

closes #13585
fixes #9067
fixes #2386
ref #6226
ref #6219
fixes #745

This PR adds support to process incoming emails to perform actions.
Currently I added handling of replies and unsubscribing from
issues/pulls. In contrast to #13585 the IMAP IDLE command is used
instead of polling which results (in my opinion 😉) in cleaner code.

Procedure:
- When sending an issue/pull reply email, a token is generated which is
present in the Reply-To and References header.
- IMAP IDLE waits until a new email arrives
- The token tells which action should be performed

A possible signature and/or reply gets stripped from the content.

I added a new service to the drone pipeline to test the receiving of
incoming mails. If we keep this in, we may test our outgoing emails too
in future.

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>

authored by

KN4CK3R
silverwind
Lunny Xiao
and committed by
GitHub
fc037b4b 20e3ffd2

+1524 -38
+4
.drone.yml
··· 230 230 MINIO_ACCESS_KEY: 123456 231 231 MINIO_SECRET_KEY: 12345678 232 232 233 + - name: smtpimap 234 + image: tabascoterrier/docker-imap-devel:latest 235 + pull: always 236 + 233 237 steps: 234 238 - name: fetch-tags 235 239 image: docker:git
+25
assets/go-licenses.json
··· 210 210 "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"{}\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright {yyyy} {name of copyright owner}\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" 211 211 }, 212 212 { 213 + "name": "github.com/cention-sany/utf7", 214 + "path": "github.com/cention-sany/utf7/LICENSE", 215 + "licenseText": "Copyright (c) 2013 The Go-IMAP Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\n notice, this list of conditions and the following disclaimer.\n\n * Redistributions in binary form must reproduce the above copyright\n notice, this list of conditions and the following disclaimer in the\n documentation and/or other materials provided with the\n distribution.\n\n * Neither the name of the go-imap project nor the names of its\n contributors may be used to endorse or promote products derived\n from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" 216 + }, 217 + { 213 218 "name": "github.com/cespare/xxhash/v2", 214 219 "path": "github.com/cespare/xxhash/v2/LICENSE.txt", 215 220 "licenseText": "Copyright (c) 2016 Caleb Spare\n\nMIT License\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" ··· 253 258 "name": "github.com/dgryski/go-rendezvous", 254 259 "path": "github.com/dgryski/go-rendezvous/LICENSE", 255 260 "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2017-2020 Damian Gryski \u003cdamian@gryski.com\u003e\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" 261 + }, 262 + { 263 + "name": "github.com/dimiro1/reply", 264 + "path": "github.com/dimiro1/reply/LICENSE", 265 + "licenseText": "MIT License\n\nCopyright (c) Discourse\nCopyright (c) Claudemiro\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" 256 266 }, 257 267 { 258 268 "name": "github.com/djherbis/buffer", ··· 283 293 "name": "github.com/editorconfig/editorconfig-core-go/v2", 284 294 "path": "github.com/editorconfig/editorconfig-core-go/v2/LICENSE", 285 295 "licenseText": "MIT License\nCopyright (c) 2016 The Editorconfig Team\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" 296 + }, 297 + { 298 + "name": "github.com/emersion/go-imap", 299 + "path": "github.com/emersion/go-imap/LICENSE", 300 + "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2013 The Go-IMAP Authors\nCopyright (c) 2016 emersion\nCopyright (c) 2016 Proton Technologies AG\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" 301 + }, 302 + { 303 + "name": "github.com/emersion/go-sasl", 304 + "path": "github.com/emersion/go-sasl/LICENSE", 305 + "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2016 emersion\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" 286 306 }, 287 307 { 288 308 "name": "github.com/ethantkoenig/rupture", ··· 523 543 "name": "github.com/jaytaylor/html2text", 524 544 "path": "github.com/jaytaylor/html2text/LICENSE", 525 545 "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2015 Jay Taylor\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n" 546 + }, 547 + { 548 + "name": "github.com/jhillyerd/enmime", 549 + "path": "github.com/jhillyerd/enmime/LICENSE", 550 + "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2012-2016 James Hillyerd, All Rights Reserved\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" 526 551 }, 527 552 { 528 553 "name": "github.com/josharian/intern",
+41
custom/conf/app.example.ini
··· 1666 1666 1667 1667 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 1668 1668 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 1669 + ;[email.incoming] 1670 + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 1671 + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 1672 + ;; 1673 + ;; Enable handling of incoming emails. 1674 + ;ENABLED = false 1675 + ;; 1676 + ;; The email address including the %{token} placeholder that will be replaced per user/action. 1677 + ;; Example: incoming+%{token}@example.com 1678 + ;; The placeholder must appear in the user part of the address (before the @). 1679 + ;REPLY_TO_ADDRESS = 1680 + ;; 1681 + ;; IMAP server host 1682 + ;HOST = 1683 + ;; 1684 + ;; IMAP server port 1685 + ;PORT = 1686 + ;; 1687 + ;; Username of the receiving account 1688 + ;USERNAME = 1689 + ;; 1690 + ;; Password of the receiving account 1691 + ;PASSWORD = 1692 + ;; 1693 + ;; Whether the IMAP server uses TLS. 1694 + ;USE_TLS = false 1695 + ;; 1696 + ;; If set to true, completely ignores server certificate validation errors. This option is unsafe. 1697 + ;SKIP_TLS_VERIFY = true 1698 + ;; 1699 + ;; The mailbox name where incoming mail will end up. 1700 + ;MAILBOX = INBOX 1701 + ;; 1702 + ;; Whether handled messages should be deleted from the mailbox. 1703 + ;DELETE_HANDLED_MESSAGE = true 1704 + ;; 1705 + ;; Maximum size of a message to handle. Bigger messages are ignored. Set to 0 to allow every size. 1706 + ;MAXIMUM_MESSAGE_SIZE = 10485760 1707 + 1708 + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 1709 + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 1669 1710 ;[cache] 1670 1711 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 1671 1712 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+14
docs/content/doc/advanced/config-cheat-sheet.en-us.md
··· 750 750 - `SEND_BUFFER_LEN`: **100**: Buffer length of mailing queue. **DEPRECATED** use `LENGTH` in `[queue.mailer]` 751 751 - `SEND_AS_PLAIN_TEXT`: **false**: Send mails only in plain text, without HTML alternative. 752 752 753 + ## Incoming Email (`email.incoming`) 754 + 755 + - `ENABLED`: **false**: Enable handling of incoming emails. 756 + - `REPLY_TO_ADDRESS`: **\<empty\>**: The email address including the `%{token}` placeholder that will be replaced per user/action. Example: `incoming+%{token}@example.com`. The placeholder must appear in the user part of the address (before the `@`). 757 + - `HOST`: **\<empty\>**: IMAP server host. 758 + - `PORT`: **\<empty\>**: IMAP server port. 759 + - `USERNAME`: **\<empty\>**: Username of the receiving account. 760 + - `PASSWORD`: **\<empty\>**: Password of the receiving account. 761 + - `USE_TLS`: **false**: Whether the IMAP server uses TLS. 762 + - `SKIP_TLS_VERIFY`: **false**: If set to `true`, completely ignores server certificate validation errors. This option is unsafe. 763 + - `MAILBOX`: **INBOX**: The mailbox name where incoming mail will end up. 764 + - `DELETE_HANDLED_MESSAGE`: **true**: Whether handled messages should be deleted from the mailbox. 765 + - `MAXIMUM_MESSAGE_SIZE`: **10485760**: Maximum size of a message to handle. Bigger messages are ignored. Set to 0 to allow every size. 766 + 753 767 ## Cache (`cache`) 754 768 755 769 - `ENABLED`: **true**: Enable the cache.
+1 -1
docs/content/doc/features/comparison.en-us.md
··· 106 106 | Issue search | ✓ | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | 107 107 | Global issue search | [/](https://github.com/go-gitea/gitea/issues/2434) | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | 108 108 | Issue dependency | ✓ | ✘ | ✘ | ✘ | ✘ | ✘ | ✘ | 109 - | Create issue via email | [✘](https://github.com/go-gitea/gitea/issues/6226) | ✘ | ✘ | ✘ | ✓ | ✓ | ✘ | 109 + | Create issue via email | [✘](https://github.com/go-gitea/gitea/issues/6226) | ✘ | ✘ | ✓ | ✓ | ✓ | ✘ | 110 110 | Service Desk | [✘](https://github.com/go-gitea/gitea/issues/6219) | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | 111 111 112 112 ## Pull/Merge requests
+1 -1
docs/content/doc/features/comparison.zh-cn.md
··· 92 92 | 工单搜索 | ✓ | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | 93 93 | 工单全局搜索 | [✘](https://github.com/go-gitea/gitea/issues/2434) | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | 94 94 | 工单依赖关系 | ✓ | ✘ | ✘ | ✘ | ✘ | ✘ | ✘ | 95 - | 通过 Email 创建工单 | [✘](https://github.com/go-gitea/gitea/issues/6226) | [✘](https://github.com/gogs/gogs/issues/2602) | ✘ | ✘ | ✓ | ✓ | ✘ | 95 + | 通过 Email 创建工单 | [✘](https://github.com/go-gitea/gitea/issues/6226) | [✘](https://github.com/gogs/gogs/issues/2602) | ✘ | ✓ | ✓ | ✓ | ✘ | 96 96 | 服务台 | [✘](https://github.com/go-gitea/gitea/issues/6219) | ✘ | ✘ | [✓](https://gitlab.com/groups/gitlab-org/-/epics/3103) | ✓ | ✘ | ✘ | 97 97 98 98 #### Pull/Merge requests
+1 -1
docs/content/doc/features/comparison.zh-tw.md
··· 93 93 | 問題搜尋 | ✓ | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | 94 94 | 全域問題搜尋 | [✘](https://github.com/go-gitea/gitea/issues/2434) | ✘ | ✓ | ✓ | ✓ | ✓ | ✘ | 95 95 | 問題相依 | ✓ | ✘ | ✘ | ✘ | ✘ | ✘ | ✘ | 96 - | 從電子郵件建立問題 | [✘](https://github.com/go-gitea/gitea/issues/6226) | [✘](https://github.com/gogs/gogs/issues/2602) | ✘ | ✘ | ✓ | ✓ | ✘ | 96 + | 從電子郵件建立問題 | [✘](https://github.com/go-gitea/gitea/issues/6226) | [✘](https://github.com/gogs/gogs/issues/2602) | ✘ | ✓ | ✓ | ✓ | ✘ | 97 97 | 服務台 | [✘](https://github.com/go-gitea/gitea/issues/6219) | ✘ | ✘ | [✓](https://gitlab.com/groups/gitlab-org/-/epics/3103) | ✓ | ✘ | ✘ | 98 98 99 99 ## 拉取/合併請求
+47
docs/content/doc/usage/incoming-email.en-us.md
··· 1 + --- 2 + date: "2022-12-01T00:00:00+00:00" 3 + title: "Incoming Email" 4 + slug: "incoming-email" 5 + draft: false 6 + toc: false 7 + menu: 8 + sidebar: 9 + parent: "usage" 10 + name: "Incoming Email" 11 + weight: 13 12 + identifier: "incoming-email" 13 + --- 14 + 15 + # Incoming Email 16 + 17 + Gitea supports the execution of several actions through incoming mails. This page describes how to set this up. 18 + 19 + **Table of Contents** 20 + 21 + {{< toc >}} 22 + 23 + ## Requirements 24 + 25 + Handling incoming email messages requires an IMAP-enabled email account. 26 + The recommended strategy is to use [email sub-addressing](https://en.wikipedia.org/wiki/Email_address#Sub-addressing) but a catch-all mailbox does work too. 27 + The receiving email address contains a user/action specific token which tells Gitea which action should be performed. 28 + This token is expected in the `To` and `Delivered-To` header fields. 29 + 30 + Gitea tries to detect automatic responses to skip and the email server should be configured to reduce the incoming noise too (spam, newsletter). 31 + 32 + ## Configuration 33 + 34 + To activate the handling of incoming email messages you have to configure the `email.incoming` section in the configuration file. 35 + 36 + The `REPLY_TO_ADDRESS` contains the address an email client will respond to. 37 + This address needs to contain the `%{token}` placeholder which will be replaced with a token describing the user/action. 38 + This placeholder must only appear once in the address and must be in the user part of the address (before the `@`). 39 + 40 + An example using email sub-addressing may look like this: `incoming+%{token}@example.com` 41 + 42 + If a catch-all mailbox is used, the placeholder may be used anywhere in the user part of the address: `incoming+%{token}@example.com`, `incoming_%{token}@example.com`, `%{token}@example.com` 43 + 44 + ## Security 45 + 46 + Be careful when choosing the domain used for receiving incoming email. 47 + It's recommended receiving incoming email on a subdomain, such as `incoming.example.com` to prevent potential security problems with other services running on `example.com`.
+6 -1
go.mod
··· 20 20 github.com/buildkite/terminal-to-html/v3 v3.7.0 21 21 github.com/caddyserver/certmagic v0.17.2 22 22 github.com/chi-middleware/proxy v1.1.1 23 - github.com/denisenkom/go-mssqldb v0.12.3 23 + github.com/denisenkom/go-mssqldb v0.12.2 24 + github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 24 25 github.com/djherbis/buffer v1.2.0 25 26 github.com/djherbis/nio/v3 v3.0.1 26 27 github.com/dustin/go-humanize v1.0.0 27 28 github.com/editorconfig/editorconfig-core-go/v2 v2.5.1 29 + github.com/emersion/go-imap v1.2.1 28 30 github.com/emirpasic/gods v1.18.1 29 31 github.com/ethantkoenig/rupture v1.0.1 30 32 github.com/felixge/fgprof v0.9.3 ··· 58 60 github.com/hashicorp/golang-lru v0.6.0 59 61 github.com/huandu/xstrings v1.4.0 60 62 github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba 63 + github.com/jhillyerd/enmime v0.10.1 61 64 github.com/json-iterator/go v1.1.12 62 65 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 63 66 github.com/keybase/go-crypto v0.0.0-20200123153347-de78d2cb44f4 ··· 145 148 github.com/blevesearch/zapx/v15 v15.3.8 // indirect 146 149 github.com/boombuler/barcode v1.0.1 // indirect 147 150 github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b // indirect 151 + github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect 148 152 github.com/cespare/xxhash/v2 v2.1.2 // indirect 149 153 github.com/cloudflare/circl v1.2.0 // indirect 150 154 github.com/couchbase/go-couchbase v0.0.0-20210224140812-5740cd35f448 // indirect ··· 155 159 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 156 160 github.com/dlclark/regexp2 v1.7.0 // indirect 157 161 github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect 162 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect 158 163 github.com/felixge/httpsnoop v1.0.3 // indirect 159 164 github.com/fxamacker/cbor/v2 v2.4.0 // indirect 160 165 github.com/go-ap/errors v0.0.0-20221205040414-01c1adfc98ea // indirect
+22 -2
go.sum
··· 234 234 github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= 235 235 github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 236 236 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 237 + github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= 238 + github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= 237 239 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 238 240 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 239 241 github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= ··· 294 296 github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= 295 297 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE= 296 298 github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= 297 - github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw= 298 - github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo= 299 + github.com/denisenkom/go-mssqldb v0.12.2 h1:1OcPn5GBIobjWNd+8yjfHNIaFX14B1pWI3F9HZy5KXw= 300 + github.com/denisenkom/go-mssqldb v0.12.2/go.mod h1:lnIw1mZukFRZDJYQ0Pb833QS2IaC3l5HkEfra2LJ+sk= 299 301 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 300 302 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 301 303 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 302 304 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 305 + github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 h1:PdsjTl0Cg+ZJgOx/CFV5NNgO1ThTreqdgKYiDCMHJwA= 306 + github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21/go.mod h1:xJvkyD6Y2rZapGvPJLYo9dyx1s5dxBEDPa8T3YTuOk0= 303 307 github.com/djherbis/buffer v1.1.0/go.mod h1:VwN8VdFkMY0DCALdY8o00d3IZ6Amz/UNVMWcSaJT44o= 304 308 github.com/djherbis/buffer v1.2.0 h1:PH5Dd2ss0C7CRRhQCZ2u7MssF+No9ide8Ye71nPHcrQ= 305 309 github.com/djherbis/buffer v1.2.0/go.mod h1:fjnebbZjCUpPinBRD+TDwXSOeNQ7fPQWLfGQqiAiUyE= 306 310 github.com/djherbis/nio/v3 v3.0.1 h1:6wxhnuppteMa6RHA4L81Dq7ThkZH8SwnDzXDYy95vB4= 307 311 github.com/djherbis/nio/v3 v3.0.1/go.mod h1:Ng4h80pbZFMla1yKzm61cF0tqqilXZYrogmWgZxOcmg= 312 + github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 308 313 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 309 314 github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= 310 315 github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= ··· 324 329 github.com/editorconfig/editorconfig-core-go/v2 v2.5.1/go.mod h1:9l0WF7U8RrFunzIpbUGLh1TIRUgDrfy0mpkyv8T7q9M= 325 330 github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= 326 331 github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= 332 + github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= 333 + github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= 334 + github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= 335 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= 336 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 337 + github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= 327 338 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 328 339 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 329 340 github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= ··· 456 467 github.com/go-swagger/go-swagger v0.30.3 h1:HuzvdMRed/9Q8vmzVcfNBQByZVtT79DNZxZ18OprdoI= 457 468 github.com/go-swagger/go-swagger v0.30.3/go.mod h1:neDPes8r8PCz2JPvHRDj8BTULLh4VJUt7n6MpQqxhHM= 458 469 github.com/go-swagger/scan-repo-boundary v0.0.0-20180623220736-973b3573c013 h1:l9rI6sNaZgNC0LnF3MiE+qTmyBA/tZAg1rtyrGbUMK0= 470 + github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M= 471 + github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= 459 472 github.com/go-testfixtures/testfixtures/v3 v3.8.1 h1:uonwvepqRvSgddcrReZQhojTlWlmOlHkYAb9ZaOMWgU= 460 473 github.com/go-testfixtures/testfixtures/v3 v3.8.1/go.mod h1:Kdu7YeMC0KRXVHdaQ91Vmx3pcjoTF63h4f1qTJDdXLA= 461 474 github.com/go-webauthn/revoke v0.1.6 h1:3tv+itza9WpX5tryRQx4GwxCCBrCIiJ8GIkOhxiAmmU= ··· 497 510 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 498 511 github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 499 512 github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 513 + github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= 500 514 github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= 501 515 github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= 502 516 github.com/gogs/cron v0.0.0-20171120032916-9f6c956d3e14 h1:yXtpJr/LV6PFu4nTLgfjQdcMdzjbqqXMEnHfq0Or6p8= ··· 757 771 github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 758 772 github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 759 773 github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da/go.mod h1:ks+b9deReOc7jgqp+e7LuFiCBH6Rm5hL32cLcEAArb4= 774 + github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= 760 775 github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg= 761 776 github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= 762 777 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 763 778 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 764 779 github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= 765 780 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 781 + github.com/jhillyerd/enmime v0.10.1 h1:3VP8gFhK7R948YJBrna5bOgnTXEuPAoICo79kKkBKfA= 782 + github.com/jhillyerd/enmime v0.10.1/go.mod h1:Qpe8EEemJMFAF8+NZoWdpXvK2Yb9dRF0k/z6mkcDHsA= 766 783 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= 767 784 github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 768 785 github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= ··· 876 893 github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 877 894 github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 878 895 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 896 + github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 879 897 github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 880 898 github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 881 899 github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= ··· 1064 1082 github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= 1065 1083 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= 1066 1084 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 1085 + github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 1067 1086 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 1068 1087 github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= 1069 1088 github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= ··· 1410 1429 golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= 1411 1430 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 1412 1431 golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= 1432 + golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 1413 1433 golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 1414 1434 golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 1415 1435 golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+2
models/unittest/testdb.go
··· 106 106 107 107 setting.Git.HomePath = filepath.Join(setting.AppDataPath, "home") 108 108 109 + setting.IncomingEmail.ReplyToAddress = "incoming+%{token}@localhost" 110 + 109 111 if err = storage.Init(); err != nil { 110 112 fatalTestError("storage.Init: %v\n", err) 111 113 }
+73
modules/setting/incoming_email.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package setting 5 + 6 + import ( 7 + "fmt" 8 + "net/mail" 9 + "strings" 10 + 11 + "code.gitea.io/gitea/modules/log" 12 + ) 13 + 14 + var IncomingEmail = struct { 15 + Enabled bool 16 + ReplyToAddress string 17 + TokenPlaceholder string `ini:"-"` 18 + Host string 19 + Port int 20 + UseTLS bool `ini:"USE_TLS"` 21 + SkipTLSVerify bool `ini:"SKIP_TLS_VERIFY"` 22 + Username string 23 + Password string 24 + Mailbox string 25 + DeleteHandledMessage bool 26 + MaximumMessageSize uint32 27 + }{ 28 + Mailbox: "INBOX", 29 + DeleteHandledMessage: true, 30 + TokenPlaceholder: "%{token}", 31 + MaximumMessageSize: 10485760, 32 + } 33 + 34 + func newIncomingEmail() { 35 + if err := Cfg.Section("email.incoming").MapTo(&IncomingEmail); err != nil { 36 + log.Fatal("Unable to map [email.incoming] section on to IncomingEmail. Error: %v", err) 37 + } 38 + 39 + if !IncomingEmail.Enabled { 40 + return 41 + } 42 + 43 + if err := checkReplyToAddress(IncomingEmail.ReplyToAddress); err != nil { 44 + log.Fatal("Invalid incoming_mail.REPLY_TO_ADDRESS (%s): %v", IncomingEmail.ReplyToAddress, err) 45 + } 46 + } 47 + 48 + func checkReplyToAddress(address string) error { 49 + parsed, err := mail.ParseAddress(IncomingEmail.ReplyToAddress) 50 + if err != nil { 51 + return err 52 + } 53 + 54 + if parsed.Name != "" { 55 + return fmt.Errorf("name must not be set") 56 + } 57 + 58 + c := strings.Count(IncomingEmail.ReplyToAddress, IncomingEmail.TokenPlaceholder) 59 + switch c { 60 + case 0: 61 + return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder) 62 + case 1: 63 + default: 64 + return fmt.Errorf("%s must appear only once", IncomingEmail.TokenPlaceholder) 65 + } 66 + 67 + parts := strings.Split(IncomingEmail.ReplyToAddress, "@") 68 + if !strings.Contains(parts[0], IncomingEmail.TokenPlaceholder) { 69 + return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder) 70 + } 71 + 72 + return nil 73 + }
+1
modules/setting/setting.go
··· 1341 1341 newSessionService() 1342 1342 newCORSService() 1343 1343 parseMailerConfig(Cfg) 1344 + newIncomingEmail() 1344 1345 newRegisterMailService() 1345 1346 newNotifyMailService() 1346 1347 newProxyService()
+33
modules/util/pack.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package util 5 + 6 + import ( 7 + "bytes" 8 + "encoding/gob" 9 + ) 10 + 11 + // PackData uses gob to encode the given data in sequence 12 + func PackData(data ...interface{}) ([]byte, error) { 13 + var buf bytes.Buffer 14 + enc := gob.NewEncoder(&buf) 15 + for _, datum := range data { 16 + if err := enc.Encode(datum); err != nil { 17 + return nil, err 18 + } 19 + } 20 + return buf.Bytes(), nil 21 + } 22 + 23 + // UnpackData uses gob to decode the given data in sequence 24 + func UnpackData(buf []byte, data ...interface{}) error { 25 + r := bytes.NewReader(buf) 26 + enc := gob.NewDecoder(r) 27 + for _, datum := range data { 28 + if err := enc.Decode(datum); err != nil { 29 + return err 30 + } 31 + } 32 + return nil 33 + }
+28
modules/util/pack_test.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package util 5 + 6 + import ( 7 + "testing" 8 + 9 + "github.com/stretchr/testify/assert" 10 + ) 11 + 12 + func TestPackAndUnpackData(t *testing.T) { 13 + s := "string" 14 + i := int64(4) 15 + f := float32(4.1) 16 + 17 + var s2 string 18 + var i2 int64 19 + var f2 float32 20 + 21 + data, err := PackData(s, i, f) 22 + assert.NoError(t, err) 23 + 24 + assert.NoError(t, UnpackData(data, &s2, &i2, &f2)) 25 + assert.NoError(t, UnpackData(data, &s2)) 26 + assert.Error(t, UnpackData(data, &i2)) 27 + assert.Error(t, UnpackData(data, &s2, &f2)) 28 + }
+2
routers/init.go
··· 40 40 "code.gitea.io/gitea/services/automerge" 41 41 "code.gitea.io/gitea/services/cron" 42 42 "code.gitea.io/gitea/services/mailer" 43 + mailer_incoming "code.gitea.io/gitea/services/mailer/incoming" 43 44 markup_service "code.gitea.io/gitea/services/markup" 44 45 repo_migrations "code.gitea.io/gitea/services/migrations" 45 46 mirror_service "code.gitea.io/gitea/services/mirror" ··· 162 163 mustInit(task.Init) 163 164 mustInit(repo_migrations.Init) 164 165 eventsource.GetManager().Init() 166 + mustInitCtx(ctx, mailer_incoming.Init) 165 167 166 168 mustInitCtx(ctx, syncAppConfForGit) 167 169
+375
services/mailer/incoming/incoming.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package incoming 5 + 6 + import ( 7 + "context" 8 + "crypto/tls" 9 + "fmt" 10 + net_mail "net/mail" 11 + "regexp" 12 + "strings" 13 + "time" 14 + 15 + "code.gitea.io/gitea/modules/log" 16 + "code.gitea.io/gitea/modules/process" 17 + "code.gitea.io/gitea/modules/setting" 18 + "code.gitea.io/gitea/services/mailer/token" 19 + 20 + "github.com/dimiro1/reply" 21 + "github.com/emersion/go-imap" 22 + "github.com/emersion/go-imap/client" 23 + "github.com/jhillyerd/enmime" 24 + ) 25 + 26 + var ( 27 + addressTokenRegex *regexp.Regexp 28 + referenceTokenRegex *regexp.Regexp 29 + ) 30 + 31 + func Init(ctx context.Context) error { 32 + if !setting.IncomingEmail.Enabled { 33 + return nil 34 + } 35 + 36 + var err error 37 + addressTokenRegex, err = regexp.Compile( 38 + fmt.Sprintf( 39 + `\A%s\z`, 40 + strings.Replace(regexp.QuoteMeta(setting.IncomingEmail.ReplyToAddress), regexp.QuoteMeta(setting.IncomingEmail.TokenPlaceholder), "(.+)", 1), 41 + ), 42 + ) 43 + if err != nil { 44 + return err 45 + } 46 + referenceTokenRegex, err = regexp.Compile(fmt.Sprintf(`\Areply-(.+)@%s\z`, regexp.QuoteMeta(setting.Domain))) 47 + if err != nil { 48 + return err 49 + } 50 + 51 + go func() { 52 + ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Incoming Email", process.SystemProcessType, true) 53 + defer finished() 54 + 55 + // This background job processes incoming emails. It uses the IMAP IDLE command to get notified about incoming emails. 56 + // The following loop restarts the processing logic after errors until ctx indicates to stop. 57 + 58 + for { 59 + select { 60 + case <-ctx.Done(): 61 + return 62 + default: 63 + if err := processIncomingEmails(ctx); err != nil { 64 + log.Error("Error while processing incoming emails: %v", err) 65 + } 66 + select { 67 + case <-ctx.Done(): 68 + return 69 + case <-time.NewTimer(10 * time.Second).C: 70 + } 71 + } 72 + } 73 + }() 74 + 75 + return nil 76 + } 77 + 78 + // processIncomingEmails is the "main" method with the wait/process loop 79 + func processIncomingEmails(ctx context.Context) error { 80 + server := fmt.Sprintf("%s:%d", setting.IncomingEmail.Host, setting.IncomingEmail.Port) 81 + 82 + var c *client.Client 83 + var err error 84 + if setting.IncomingEmail.UseTLS { 85 + c, err = client.DialTLS(server, &tls.Config{InsecureSkipVerify: setting.IncomingEmail.SkipTLSVerify}) 86 + } else { 87 + c, err = client.Dial(server) 88 + } 89 + if err != nil { 90 + return fmt.Errorf("could not connect to server '%s': %w", server, err) 91 + } 92 + 93 + if err := c.Login(setting.IncomingEmail.Username, setting.IncomingEmail.Password); err != nil { 94 + return fmt.Errorf("could not login: %w", err) 95 + } 96 + defer func() { 97 + if err := c.Logout(); err != nil { 98 + log.Error("Logout from incoming email server failed: %v", err) 99 + } 100 + }() 101 + 102 + if _, err := c.Select(setting.IncomingEmail.Mailbox, false); err != nil { 103 + return fmt.Errorf("selecting box '%s' failed: %w", setting.IncomingEmail.Mailbox, err) 104 + } 105 + 106 + // The following loop processes messages. If there are no messages available, IMAP IDLE is used to wait for new messages. 107 + // This process is repeated until an IMAP error occurs or ctx indicates to stop. 108 + 109 + for { 110 + select { 111 + case <-ctx.Done(): 112 + return nil 113 + default: 114 + if err := processMessages(ctx, c); err != nil { 115 + return fmt.Errorf("could not process messages: %w", err) 116 + } 117 + if err := waitForUpdates(ctx, c); err != nil { 118 + return fmt.Errorf("wait for updates failed: %w", err) 119 + } 120 + select { 121 + case <-ctx.Done(): 122 + return nil 123 + case <-time.NewTimer(time.Second).C: 124 + } 125 + } 126 + } 127 + } 128 + 129 + // waitForUpdates uses IMAP IDLE to wait for new emails 130 + func waitForUpdates(ctx context.Context, c *client.Client) error { 131 + updates := make(chan client.Update, 1) 132 + 133 + c.Updates = updates 134 + defer func() { 135 + c.Updates = nil 136 + }() 137 + 138 + errs := make(chan error, 1) 139 + stop := make(chan struct{}) 140 + go func() { 141 + errs <- c.Idle(stop, nil) 142 + }() 143 + 144 + stopped := false 145 + for { 146 + select { 147 + case update := <-updates: 148 + switch update.(type) { 149 + case *client.MailboxUpdate: 150 + if !stopped { 151 + close(stop) 152 + stopped = true 153 + } 154 + default: 155 + } 156 + case err := <-errs: 157 + if err != nil { 158 + return fmt.Errorf("imap idle failed: %w", err) 159 + } 160 + return nil 161 + case <-ctx.Done(): 162 + return nil 163 + } 164 + } 165 + } 166 + 167 + // processMessages searches unread mails and processes them. 168 + func processMessages(ctx context.Context, c *client.Client) error { 169 + criteria := imap.NewSearchCriteria() 170 + criteria.WithoutFlags = []string{imap.SeenFlag} 171 + criteria.Smaller = setting.IncomingEmail.MaximumMessageSize 172 + ids, err := c.Search(criteria) 173 + if err != nil { 174 + return fmt.Errorf("imap search failed: %w", err) 175 + } 176 + 177 + if len(ids) == 0 { 178 + return nil 179 + } 180 + 181 + seqset := new(imap.SeqSet) 182 + seqset.AddNum(ids...) 183 + messages := make(chan *imap.Message, 10) 184 + 185 + section := &imap.BodySectionName{} 186 + 187 + errs := make(chan error, 1) 188 + go func() { 189 + errs <- c.Fetch( 190 + seqset, 191 + []imap.FetchItem{section.FetchItem()}, 192 + messages, 193 + ) 194 + }() 195 + 196 + handledSet := new(imap.SeqSet) 197 + loop: 198 + for { 199 + select { 200 + case <-ctx.Done(): 201 + break loop 202 + case msg, ok := <-messages: 203 + if !ok { 204 + if setting.IncomingEmail.DeleteHandledMessage && !handledSet.Empty() { 205 + if err := c.Store( 206 + handledSet, 207 + imap.FormatFlagsOp(imap.AddFlags, true), 208 + []interface{}{imap.DeletedFlag}, 209 + nil, 210 + ); err != nil { 211 + return fmt.Errorf("imap store failed: %w", err) 212 + } 213 + 214 + if err := c.Expunge(nil); err != nil { 215 + return fmt.Errorf("imap expunge failed: %w", err) 216 + } 217 + } 218 + return nil 219 + } 220 + 221 + err := func() error { 222 + r := msg.GetBody(section) 223 + if r == nil { 224 + return fmt.Errorf("could not get body from message: %w", err) 225 + } 226 + 227 + env, err := enmime.ReadEnvelope(r) 228 + if err != nil { 229 + return fmt.Errorf("could not read envelope: %w", err) 230 + } 231 + 232 + if isAutomaticReply(env) { 233 + log.Debug("Skipping automatic email reply") 234 + return nil 235 + } 236 + 237 + t := searchTokenInHeaders(env) 238 + if t == "" { 239 + log.Debug("Incoming email token not found in headers") 240 + return nil 241 + } 242 + 243 + handlerType, user, payload, err := token.ExtractToken(ctx, t) 244 + if err != nil { 245 + if _, ok := err.(*token.ErrToken); ok { 246 + log.Info("Invalid incoming email token: %v", err) 247 + return nil 248 + } 249 + return err 250 + } 251 + 252 + handler, ok := handlers[handlerType] 253 + if !ok { 254 + return fmt.Errorf("unexpected handler type: %v", handlerType) 255 + } 256 + 257 + content := getContentFromMailReader(env) 258 + 259 + if err := handler.Handle(ctx, content, user, payload); err != nil { 260 + return fmt.Errorf("could not handle message: %w", err) 261 + } 262 + 263 + handledSet.AddNum(msg.SeqNum) 264 + 265 + return nil 266 + }() 267 + if err != nil { 268 + log.Error("Error while processing incoming email[%v]: %v", msg.Uid, err) 269 + } 270 + } 271 + } 272 + 273 + if err := <-errs; err != nil { 274 + return fmt.Errorf("imap fetch failed: %w", err) 275 + } 276 + 277 + return nil 278 + } 279 + 280 + // isAutomaticReply tests if the headers indicate an automatic reply 281 + func isAutomaticReply(env *enmime.Envelope) bool { 282 + autoSubmitted := env.GetHeader("Auto-Submitted") 283 + if autoSubmitted != "" && autoSubmitted != "no" { 284 + return true 285 + } 286 + autoReply := env.GetHeader("X-Autoreply") 287 + if autoReply == "yes" { 288 + return true 289 + } 290 + autoRespond := env.GetHeader("X-Autorespond") 291 + return autoRespond != "" 292 + } 293 + 294 + // searchTokenInHeaders looks for the token in To, Delivered-To and References 295 + func searchTokenInHeaders(env *enmime.Envelope) string { 296 + if addressTokenRegex != nil { 297 + to, _ := env.AddressList("To") 298 + 299 + token := searchTokenInAddresses(to) 300 + if token != "" { 301 + return token 302 + } 303 + 304 + deliveredTo, _ := env.AddressList("Delivered-To") 305 + 306 + token = searchTokenInAddresses(deliveredTo) 307 + if token != "" { 308 + return token 309 + } 310 + } 311 + 312 + references := env.GetHeader("References") 313 + for { 314 + begin := strings.IndexByte(references, '<') 315 + if begin == -1 { 316 + break 317 + } 318 + begin++ 319 + 320 + end := strings.IndexByte(references, '>') 321 + if end == -1 || begin > end { 322 + break 323 + } 324 + 325 + match := referenceTokenRegex.FindStringSubmatch(references[begin:end]) 326 + if len(match) == 2 { 327 + return match[1] 328 + } 329 + 330 + references = references[end+1:] 331 + } 332 + 333 + return "" 334 + } 335 + 336 + // searchTokenInAddresses looks for the token in an address 337 + func searchTokenInAddresses(addresses []*net_mail.Address) string { 338 + for _, address := range addresses { 339 + match := addressTokenRegex.FindStringSubmatch(address.Address) 340 + if len(match) != 2 { 341 + continue 342 + } 343 + 344 + return match[1] 345 + } 346 + 347 + return "" 348 + } 349 + 350 + type MailContent struct { 351 + Content string 352 + Attachments []*Attachment 353 + } 354 + 355 + type Attachment struct { 356 + Name string 357 + Content []byte 358 + } 359 + 360 + // getContentFromMailReader grabs the plain content and the attachments from the mail. 361 + // A potential reply/signature gets stripped from the content. 362 + func getContentFromMailReader(env *enmime.Envelope) *MailContent { 363 + attachments := make([]*Attachment, 0, len(env.Attachments)) 364 + for _, attachment := range env.Attachments { 365 + attachments = append(attachments, &Attachment{ 366 + Name: attachment.FileName, 367 + Content: attachment.Content, 368 + }) 369 + } 370 + 371 + return &MailContent{ 372 + Content: reply.FromText(env.Text), 373 + Attachments: attachments, 374 + } 375 + }
+171
services/mailer/incoming/incoming_handler.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package incoming 5 + 6 + import ( 7 + "bytes" 8 + "context" 9 + "fmt" 10 + 11 + issues_model "code.gitea.io/gitea/models/issues" 12 + access_model "code.gitea.io/gitea/models/perm/access" 13 + repo_model "code.gitea.io/gitea/models/repo" 14 + user_model "code.gitea.io/gitea/models/user" 15 + "code.gitea.io/gitea/modules/log" 16 + "code.gitea.io/gitea/modules/setting" 17 + "code.gitea.io/gitea/modules/upload" 18 + "code.gitea.io/gitea/modules/util" 19 + attachment_service "code.gitea.io/gitea/services/attachment" 20 + issue_service "code.gitea.io/gitea/services/issue" 21 + incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" 22 + "code.gitea.io/gitea/services/mailer/token" 23 + pull_service "code.gitea.io/gitea/services/pull" 24 + ) 25 + 26 + type MailHandler interface { 27 + Handle(ctx context.Context, content *MailContent, doer *user_model.User, payload []byte) error 28 + } 29 + 30 + var handlers = map[token.HandlerType]MailHandler{ 31 + token.ReplyHandlerType: &ReplyHandler{}, 32 + token.UnsubscribeHandlerType: &UnsubscribeHandler{}, 33 + } 34 + 35 + // ReplyHandler handles incoming emails to create a reply from them 36 + type ReplyHandler struct{} 37 + 38 + func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *user_model.User, payload []byte) error { 39 + if doer == nil { 40 + return util.NewInvalidArgumentErrorf("doer can't be nil") 41 + } 42 + 43 + ref, err := incoming_payload.GetReferenceFromPayload(ctx, payload) 44 + if err != nil { 45 + return err 46 + } 47 + 48 + var issue *issues_model.Issue 49 + 50 + switch r := ref.(type) { 51 + case *issues_model.Issue: 52 + issue = r 53 + case *issues_model.Comment: 54 + comment := r 55 + 56 + if err := comment.LoadIssue(ctx); err != nil { 57 + return err 58 + } 59 + 60 + issue = comment.Issue 61 + default: 62 + return util.NewInvalidArgumentErrorf("unsupported reply reference: %v", ref) 63 + } 64 + 65 + if err := issue.LoadRepo(ctx); err != nil { 66 + return err 67 + } 68 + 69 + perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) 70 + if err != nil { 71 + return err 72 + } 73 + 74 + if !perm.CanWriteIssuesOrPulls(issue.IsPull) || issue.IsLocked && !doer.IsAdmin { 75 + log.Debug("can't write issue or pull") 76 + return nil 77 + } 78 + 79 + switch r := ref.(type) { 80 + case *issues_model.Issue: 81 + attachmentIDs := make([]string, 0, len(content.Attachments)) 82 + if setting.Attachment.Enabled { 83 + for _, attachment := range content.Attachments { 84 + a, err := attachment_service.UploadAttachment(bytes.NewReader(attachment.Content), setting.Attachment.AllowedTypes, &repo_model.Attachment{ 85 + Name: attachment.Name, 86 + UploaderID: doer.ID, 87 + RepoID: issue.Repo.ID, 88 + }) 89 + if err != nil { 90 + if upload.IsErrFileTypeForbidden(err) { 91 + log.Info("Skipping disallowed attachment type: %s", attachment.Name) 92 + continue 93 + } 94 + return err 95 + } 96 + attachmentIDs = append(attachmentIDs, a.UUID) 97 + } 98 + } 99 + 100 + if content.Content == "" && len(attachmentIDs) == 0 { 101 + return nil 102 + } 103 + 104 + _, err = issue_service.CreateIssueComment(ctx, doer, issue.Repo, issue, content.Content, attachmentIDs) 105 + if err != nil { 106 + return fmt.Errorf("CreateIssueComment failed: %w", err) 107 + } 108 + case *issues_model.Comment: 109 + comment := r 110 + 111 + if content.Content == "" { 112 + return nil 113 + } 114 + 115 + if comment.Type == issues_model.CommentTypeCode { 116 + _, err := pull_service.CreateCodeComment( 117 + ctx, 118 + doer, 119 + nil, 120 + issue, 121 + comment.Line, 122 + content.Content, 123 + comment.TreePath, 124 + false, 125 + comment.ReviewID, 126 + "", 127 + ) 128 + if err != nil { 129 + return fmt.Errorf("CreateCodeComment failed: %w", err) 130 + } 131 + } 132 + } 133 + return nil 134 + } 135 + 136 + // UnsubscribeHandler handles unwatching issues/pulls 137 + type UnsubscribeHandler struct{} 138 + 139 + func (h *UnsubscribeHandler) Handle(ctx context.Context, _ *MailContent, doer *user_model.User, payload []byte) error { 140 + if doer == nil { 141 + return util.NewInvalidArgumentErrorf("doer can't be nil") 142 + } 143 + 144 + ref, err := incoming_payload.GetReferenceFromPayload(ctx, payload) 145 + if err != nil { 146 + return err 147 + } 148 + 149 + switch r := ref.(type) { 150 + case *issues_model.Issue: 151 + issue := r 152 + 153 + if err := issue.LoadRepo(ctx); err != nil { 154 + return err 155 + } 156 + 157 + perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) 158 + if err != nil { 159 + return err 160 + } 161 + 162 + if !perm.CanReadIssuesOrPulls(issue.IsPull) { 163 + log.Debug("can't read issue or pull") 164 + return nil 165 + } 166 + 167 + return issues_model.CreateOrUpdateIssueWatch(doer.ID, issue.ID, false) 168 + } 169 + 170 + return fmt.Errorf("unsupported unsubscribe reference: %v", ref) 171 + }
+138
services/mailer/incoming/incoming_test.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package incoming 5 + 6 + import ( 7 + "strings" 8 + "testing" 9 + 10 + "github.com/jhillyerd/enmime" 11 + "github.com/stretchr/testify/assert" 12 + ) 13 + 14 + func TestIsAutomaticReply(t *testing.T) { 15 + cases := []struct { 16 + Headers map[string]string 17 + Expected bool 18 + }{ 19 + { 20 + Headers: map[string]string{}, 21 + Expected: false, 22 + }, 23 + { 24 + Headers: map[string]string{ 25 + "Auto-Submitted": "no", 26 + }, 27 + Expected: false, 28 + }, 29 + { 30 + Headers: map[string]string{ 31 + "Auto-Submitted": "yes", 32 + }, 33 + Expected: true, 34 + }, 35 + { 36 + Headers: map[string]string{ 37 + "X-Autoreply": "no", 38 + }, 39 + Expected: false, 40 + }, 41 + { 42 + Headers: map[string]string{ 43 + "X-Autoreply": "yes", 44 + }, 45 + Expected: true, 46 + }, 47 + { 48 + Headers: map[string]string{ 49 + "X-Autorespond": "yes", 50 + }, 51 + Expected: true, 52 + }, 53 + } 54 + 55 + for _, c := range cases { 56 + b := enmime.Builder(). 57 + From("Dummy", "dummy@gitea.io"). 58 + To("Dummy", "dummy@gitea.io") 59 + for k, v := range c.Headers { 60 + b = b.Header(k, v) 61 + } 62 + root, err := b.Build() 63 + assert.NoError(t, err) 64 + env, err := enmime.EnvelopeFromPart(root) 65 + assert.NoError(t, err) 66 + 67 + assert.Equal(t, c.Expected, isAutomaticReply(env)) 68 + } 69 + } 70 + 71 + func TestGetContentFromMailReader(t *testing.T) { 72 + mailString := "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + 73 + "\r\n" + 74 + "--message-boundary\r\n" + 75 + "Content-Type: multipart/alternative; boundary=text-boundary\r\n" + 76 + "\r\n" + 77 + "--text-boundary\r\n" + 78 + "Content-Type: text/plain\r\n" + 79 + "Content-Disposition: inline\r\n" + 80 + "\r\n" + 81 + "mail content\r\n" + 82 + "--text-boundary--\r\n" + 83 + "--message-boundary\r\n" + 84 + "Content-Type: text/plain\r\n" + 85 + "Content-Disposition: attachment; filename=attachment.txt\r\n" + 86 + "\r\n" + 87 + "attachment content\r\n" + 88 + "--message-boundary--\r\n" 89 + 90 + env, err := enmime.ReadEnvelope(strings.NewReader(mailString)) 91 + assert.NoError(t, err) 92 + content := getContentFromMailReader(env) 93 + assert.Equal(t, "mail content", content.Content) 94 + assert.Len(t, content.Attachments, 1) 95 + assert.Equal(t, "attachment.txt", content.Attachments[0].Name) 96 + assert.Equal(t, []byte("attachment content"), content.Attachments[0].Content) 97 + 98 + mailString = "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + 99 + "\r\n" + 100 + "--message-boundary\r\n" + 101 + "Content-Type: multipart/alternative; boundary=text-boundary\r\n" + 102 + "\r\n" + 103 + "--text-boundary\r\n" + 104 + "Content-Type: text/html\r\n" + 105 + "Content-Disposition: inline\r\n" + 106 + "\r\n" + 107 + "<p>mail content</p>\r\n" + 108 + "--text-boundary--\r\n" + 109 + "--message-boundary--\r\n" 110 + 111 + env, err = enmime.ReadEnvelope(strings.NewReader(mailString)) 112 + assert.NoError(t, err) 113 + content = getContentFromMailReader(env) 114 + assert.Equal(t, "mail content", content.Content) 115 + assert.Empty(t, content.Attachments) 116 + 117 + mailString = "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + 118 + "\r\n" + 119 + "--message-boundary\r\n" + 120 + "Content-Type: multipart/alternative; boundary=text-boundary\r\n" + 121 + "\r\n" + 122 + "--text-boundary\r\n" + 123 + "Content-Type: text/plain\r\n" + 124 + "Content-Disposition: inline\r\n" + 125 + "\r\n" + 126 + "mail content without signature\r\n" + 127 + "--\r\n" + 128 + "signature\r\n" + 129 + "--text-boundary--\r\n" + 130 + "--message-boundary--\r\n" 131 + 132 + env, err = enmime.ReadEnvelope(strings.NewReader(mailString)) 133 + assert.NoError(t, err) 134 + content = getContentFromMailReader(env) 135 + assert.NoError(t, err) 136 + assert.Equal(t, "mail content without signature", content.Content) 137 + assert.Empty(t, content.Attachments) 138 + }
+70
services/mailer/incoming/payload/payload.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package payload 5 + 6 + import ( 7 + "context" 8 + 9 + issues_model "code.gitea.io/gitea/models/issues" 10 + "code.gitea.io/gitea/modules/util" 11 + ) 12 + 13 + const replyPayloadVersion1 byte = 1 14 + 15 + type payloadReferenceType byte 16 + 17 + const ( 18 + payloadReferenceIssue payloadReferenceType = iota 19 + payloadReferenceComment 20 + ) 21 + 22 + // CreateReferencePayload creates data which GetReferenceFromPayload resolves to the reference again. 23 + func CreateReferencePayload(reference interface{}) ([]byte, error) { 24 + var refType payloadReferenceType 25 + var refID int64 26 + 27 + switch r := reference.(type) { 28 + case *issues_model.Issue: 29 + refType = payloadReferenceIssue 30 + refID = r.ID 31 + case *issues_model.Comment: 32 + refType = payloadReferenceComment 33 + refID = r.ID 34 + default: 35 + return nil, util.NewInvalidArgumentErrorf("unsupported reference type: %T", r) 36 + } 37 + 38 + payload, err := util.PackData(refType, refID) 39 + if err != nil { 40 + return nil, err 41 + } 42 + 43 + return append([]byte{replyPayloadVersion1}, payload...), nil 44 + } 45 + 46 + // GetReferenceFromPayload resolves the reference from the payload 47 + func GetReferenceFromPayload(ctx context.Context, payload []byte) (interface{}, error) { 48 + if len(payload) < 1 { 49 + return nil, util.NewInvalidArgumentErrorf("payload to small") 50 + } 51 + 52 + if payload[0] != replyPayloadVersion1 { 53 + return nil, util.NewInvalidArgumentErrorf("unsupported payload version") 54 + } 55 + 56 + var ref payloadReferenceType 57 + var id int64 58 + if err := util.UnpackData(payload[1:], &ref, &id); err != nil { 59 + return nil, err 60 + } 61 + 62 + switch ref { 63 + case payloadReferenceIssue: 64 + return issues_model.GetIssueByID(ctx, id) 65 + case payloadReferenceComment: 66 + return issues_model.GetCommentByID(ctx, id) 67 + default: 68 + return nil, util.NewInvalidArgumentErrorf("unsupported reference type: %T", ref) 69 + } 70 + }
+49 -6
services/mailer/mail.go
··· 29 29 "code.gitea.io/gitea/modules/templates" 30 30 "code.gitea.io/gitea/modules/timeutil" 31 31 "code.gitea.io/gitea/modules/translation" 32 + incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" 33 + "code.gitea.io/gitea/services/mailer/token" 32 34 33 35 "gopkg.in/gomail.v2" 34 36 ) ··· 302 304 msgID := createReference(ctx.Issue, ctx.Comment, ctx.ActionType) 303 305 reference := createReference(ctx.Issue, nil, activities_model.ActionType(0)) 304 306 307 + var replyPayload []byte 308 + if ctx.Comment != nil && ctx.Comment.Type == issues_model.CommentTypeCode { 309 + replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Comment) 310 + } else { 311 + replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Issue) 312 + } 313 + if err != nil { 314 + return nil, err 315 + } 316 + 317 + unsubscribePayload, err := incoming_payload.CreateReferencePayload(ctx.Issue) 318 + if err != nil { 319 + return nil, err 320 + } 321 + 305 322 msgs := make([]*Message, 0, len(recipients)) 306 323 for _, recipient := range recipients { 307 324 msg := NewMessageFrom([]string{recipient.Email}, ctx.Doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String()) 308 325 msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info) 309 326 310 - msg.SetHeader("Message-ID", "<"+msgID+">") 311 - msg.SetHeader("In-Reply-To", "<"+reference+">") 312 - msg.SetHeader("References", "<"+reference+">") 327 + msg.SetHeader("Message-ID", msgID) 328 + msg.SetHeader("In-Reply-To", reference) 329 + 330 + references := []string{reference} 331 + listUnsubscribe := []string{"<" + ctx.Issue.HTMLURL() + ">"} 332 + 333 + if setting.IncomingEmail.Enabled { 334 + if ctx.Comment != nil { 335 + token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload) 336 + if err != nil { 337 + log.Error("CreateToken failed: %v", err) 338 + } else { 339 + replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1) 340 + msg.ReplyTo = replyAddress 341 + msg.SetHeader("List-Post", fmt.Sprintf("<mailto:%s>", replyAddress)) 342 + 343 + references = append(references, fmt.Sprintf("<reply-%s@%s>", token, setting.Domain)) 344 + } 345 + } 346 + 347 + token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload) 348 + if err != nil { 349 + log.Error("CreateToken failed: %v", err) 350 + } else { 351 + unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1) 352 + listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">") 353 + } 354 + } 355 + 356 + msg.SetHeader("References", references...) 357 + msg.SetHeader("List-Unsubscribe", listUnsubscribe...) 313 358 314 359 for key, value := range generateAdditionalHeaders(ctx, actType, recipient) { 315 360 msg.SetHeader(key, value) ··· 345 390 } 346 391 } 347 392 348 - return fmt.Sprintf("%s/%s/%d%s@%s", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain) 393 + return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain) 349 394 } 350 395 351 396 func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string { ··· 357 402 358 403 // https://datatracker.ietf.org/doc/html/rfc2369 359 404 "List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()), 360 - //"List-Post": https://github.com/go-gitea/gitea/pull/13585 361 - "List-Unsubscribe": ctx.Issue.HTMLURL(), 362 405 363 406 "X-Mailer": "Gitea", 364 407 "X-Gitea-Reason": reason,
+29 -26
services/mailer/mail_test.go
··· 8 8 "context" 9 9 "fmt" 10 10 "html/template" 11 + "regexp" 11 12 "strings" 12 13 "testing" 13 14 texttmpl "text/template" ··· 66 67 func TestComposeIssueCommentMessage(t *testing.T) { 67 68 doer, _, issue, comment := prepareMailerTest(t) 68 69 70 + setting.IncomingEmail.Enabled = true 71 + defer func() { setting.IncomingEmail.Enabled = false }() 72 + 69 73 subjectTemplates = texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl)) 70 74 bodyTemplates = template.Must(template.New("issue/comment").Parse(bodyTpl)) 71 75 ··· 78 82 assert.NoError(t, err) 79 83 assert.Len(t, msgs, 2) 80 84 gomailMsg := msgs[0].ToMessage() 81 - mailto := gomailMsg.GetHeader("To") 82 - subject := gomailMsg.GetHeader("Subject") 83 - messageID := gomailMsg.GetHeader("Message-ID") 84 - inReplyTo := gomailMsg.GetHeader("In-Reply-To") 85 - references := gomailMsg.GetHeader("References") 85 + replyTo := gomailMsg.GetHeader("Reply-To")[0] 86 + subject := gomailMsg.GetHeader("Subject")[0] 86 87 87 - assert.Len(t, mailto, 1, "exactly one recipient is expected in the To field") 88 - assert.Equal(t, "Re: ", subject[0][:4], "Comment reply subject should contain Re:") 89 - assert.Equal(t, "Re: [user2/repo1] @user2 #1 - issue1", subject[0]) 90 - assert.Equal(t, "<user2/repo1/issues/1@localhost>", inReplyTo[0], "In-Reply-To header doesn't match") 91 - assert.Equal(t, "<user2/repo1/issues/1@localhost>", references[0], "References header doesn't match") 92 - assert.Equal(t, "<user2/repo1/issues/1/comment/2@localhost>", messageID[0], "Message-ID header doesn't match") 88 + assert.Len(t, gomailMsg.GetHeader("To"), 1, "exactly one recipient is expected in the To field") 89 + tokenRegex := regexp.MustCompile(`\Aincoming\+(.+)@localhost\z`) 90 + assert.Regexp(t, tokenRegex, replyTo) 91 + token := tokenRegex.FindAllStringSubmatch(replyTo, 1)[0][1] 92 + assert.Equal(t, "Re: ", subject[:4], "Comment reply subject should contain Re:") 93 + assert.Equal(t, "Re: [user2/repo1] @user2 #1 - issue1", subject) 94 + assert.Equal(t, "<user2/repo1/issues/1@localhost>", gomailMsg.GetHeader("In-Reply-To")[0], "In-Reply-To header doesn't match") 95 + assert.ElementsMatch(t, []string{"<user2/repo1/issues/1@localhost>", "<reply-" + token + "@localhost>"}, gomailMsg.GetHeader("References"), "References header doesn't match") 96 + assert.Equal(t, "<user2/repo1/issues/1/comment/2@localhost>", gomailMsg.GetHeader("Message-ID")[0], "Message-ID header doesn't match") 97 + assert.Equal(t, "<mailto:"+replyTo+">", gomailMsg.GetHeader("List-Post")[0]) 98 + assert.Len(t, gomailMsg.GetHeader("List-Unsubscribe"), 2) // url + mailto 93 99 } 94 100 95 101 func TestComposeIssueMessage(t *testing.T) { ··· 119 125 assert.Equal(t, "<user2/repo1/issues/1@localhost>", inReplyTo[0], "In-Reply-To header doesn't match") 120 126 assert.Equal(t, "<user2/repo1/issues/1@localhost>", references[0], "References header doesn't match") 121 127 assert.Equal(t, "<user2/repo1/issues/1@localhost>", messageID[0], "Message-ID header doesn't match") 128 + assert.Empty(t, gomailMsg.GetHeader("List-Post")) // incoming mail feature disabled 129 + assert.Len(t, gomailMsg.GetHeader("List-Unsubscribe"), 1) // url without mailto 122 130 } 123 131 124 132 func TestTemplateSelection(t *testing.T) { ··· 238 246 expected := map[string]string{ 239 247 "List-ID": "user2/repo1 <repo1.user2.localhost>", 240 248 "List-Archive": "<https://try.gitea.io/user2/repo1>", 241 - "List-Unsubscribe": "https://try.gitea.io/user2/repo1/issues/1", 242 249 "X-Gitea-Reason": "dummy-reason", 243 250 "X-Gitea-Sender": "< U<se>r Tw<o > ><", 244 251 "X-Gitea-Recipient": "Test", ··· 271 278 name string 272 279 args args 273 280 prefix string 274 - suffix string 275 281 }{ 276 282 { 277 283 name: "Open Issue", ··· 279 285 issue: issue, 280 286 actionType: activities_model.ActionCreateIssue, 281 287 }, 282 - prefix: fmt.Sprintf("%s/issues/%d@%s", issue.Repo.FullName(), issue.Index, setting.Domain), 288 + prefix: fmt.Sprintf("<%s/issues/%d@%s>", issue.Repo.FullName(), issue.Index, setting.Domain), 283 289 }, 284 290 { 285 291 name: "Open Pull", ··· 287 293 issue: pullIssue, 288 294 actionType: activities_model.ActionCreatePullRequest, 289 295 }, 290 - prefix: fmt.Sprintf("%s/pulls/%d@%s", issue.Repo.FullName(), issue.Index, setting.Domain), 296 + prefix: fmt.Sprintf("<%s/pulls/%d@%s>", issue.Repo.FullName(), issue.Index, setting.Domain), 291 297 }, 292 298 { 293 299 name: "Comment Issue", ··· 296 302 comment: comment, 297 303 actionType: activities_model.ActionCommentIssue, 298 304 }, 299 - prefix: fmt.Sprintf("%s/issues/%d/comment/%d@%s", issue.Repo.FullName(), issue.Index, comment.ID, setting.Domain), 305 + prefix: fmt.Sprintf("<%s/issues/%d/comment/%d@%s>", issue.Repo.FullName(), issue.Index, comment.ID, setting.Domain), 300 306 }, 301 307 { 302 308 name: "Comment Pull", ··· 305 311 comment: comment, 306 312 actionType: activities_model.ActionCommentPull, 307 313 }, 308 - prefix: fmt.Sprintf("%s/pulls/%d/comment/%d@%s", issue.Repo.FullName(), issue.Index, comment.ID, setting.Domain), 314 + prefix: fmt.Sprintf("<%s/pulls/%d/comment/%d@%s>", issue.Repo.FullName(), issue.Index, comment.ID, setting.Domain), 309 315 }, 310 316 { 311 317 name: "Close Issue", ··· 313 319 issue: issue, 314 320 actionType: activities_model.ActionCloseIssue, 315 321 }, 316 - prefix: fmt.Sprintf("%s/issues/%d/close/", issue.Repo.FullName(), issue.Index), 322 + prefix: fmt.Sprintf("<%s/issues/%d/close/", issue.Repo.FullName(), issue.Index), 317 323 }, 318 324 { 319 325 name: "Close Pull", ··· 321 327 issue: pullIssue, 322 328 actionType: activities_model.ActionClosePullRequest, 323 329 }, 324 - prefix: fmt.Sprintf("%s/pulls/%d/close/", issue.Repo.FullName(), issue.Index), 330 + prefix: fmt.Sprintf("<%s/pulls/%d/close/", issue.Repo.FullName(), issue.Index), 325 331 }, 326 332 { 327 333 name: "Reopen Issue", ··· 329 335 issue: issue, 330 336 actionType: activities_model.ActionReopenIssue, 331 337 }, 332 - prefix: fmt.Sprintf("%s/issues/%d/reopen/", issue.Repo.FullName(), issue.Index), 338 + prefix: fmt.Sprintf("<%s/issues/%d/reopen/", issue.Repo.FullName(), issue.Index), 333 339 }, 334 340 { 335 341 name: "Reopen Pull", ··· 337 343 issue: pullIssue, 338 344 actionType: activities_model.ActionReopenPullRequest, 339 345 }, 340 - prefix: fmt.Sprintf("%s/pulls/%d/reopen/", issue.Repo.FullName(), issue.Index), 346 + prefix: fmt.Sprintf("<%s/pulls/%d/reopen/", issue.Repo.FullName(), issue.Index), 341 347 }, 342 348 { 343 349 name: "Merge Pull", ··· 345 351 issue: pullIssue, 346 352 actionType: activities_model.ActionMergePullRequest, 347 353 }, 348 - prefix: fmt.Sprintf("%s/pulls/%d/merge/", issue.Repo.FullName(), issue.Index), 354 + prefix: fmt.Sprintf("<%s/pulls/%d/merge/", issue.Repo.FullName(), issue.Index), 349 355 }, 350 356 { 351 357 name: "Ready Pull", ··· 353 359 issue: pullIssue, 354 360 actionType: activities_model.ActionPullRequestReadyForReview, 355 361 }, 356 - prefix: fmt.Sprintf("%s/pulls/%d/ready/", issue.Repo.FullName(), issue.Index), 362 + prefix: fmt.Sprintf("<%s/pulls/%d/ready/", issue.Repo.FullName(), issue.Index), 357 363 }, 358 364 } 359 365 for _, tt := range tests { 360 366 t.Run(tt.name, func(t *testing.T) { 361 367 got := createReference(tt.args.issue, tt.args.comment, tt.args.actionType) 362 368 if !strings.HasPrefix(got, tt.prefix) { 363 - t.Errorf("createReference() = %v, want %v", got, tt.prefix) 364 - } 365 - if !strings.HasSuffix(got, tt.suffix) { 366 369 t.Errorf("createReference() = %v, want %v", got, tt.prefix) 367 370 } 368 371 })
+4
services/mailer/mailer.go
··· 36 36 FromAddress string 37 37 FromDisplayName string 38 38 To []string 39 + ReplyTo string 39 40 Subject string 40 41 Date time.Time 41 42 Body string ··· 47 48 msg := gomail.NewMessage() 48 49 msg.SetAddressHeader("From", m.FromAddress, m.FromDisplayName) 49 50 msg.SetHeader("To", m.To...) 51 + if m.ReplyTo != "" { 52 + msg.SetHeader("Reply-To", m.ReplyTo) 53 + } 50 54 for header := range m.Headers { 51 55 msg.SetHeader(header, m.Headers[header]...) 52 56 }
+128
services/mailer/token/token.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package token 5 + 6 + import ( 7 + "context" 8 + crypto_hmac "crypto/hmac" 9 + "crypto/sha256" 10 + "encoding/base32" 11 + "fmt" 12 + "time" 13 + 14 + user_model "code.gitea.io/gitea/models/user" 15 + "code.gitea.io/gitea/modules/util" 16 + ) 17 + 18 + // A token is a verifiable container describing an action. 19 + // 20 + // A token has a dynamic length depending on the contained data and has the following structure: 21 + // | Token Version | User ID | HMAC | Payload | 22 + // 23 + // The payload is verifiable by the generated HMAC using the user secret. It contains: 24 + // | Timestamp | Action/Handler Type | Action/Handler Data | 25 + 26 + const ( 27 + tokenVersion1 byte = 1 28 + tokenLifetimeInYears int = 1 29 + ) 30 + 31 + type HandlerType byte 32 + 33 + const ( 34 + UnknownHandlerType HandlerType = iota 35 + ReplyHandlerType 36 + UnsubscribeHandlerType 37 + ) 38 + 39 + var encodingWithoutPadding = base32.StdEncoding.WithPadding(base32.NoPadding) 40 + 41 + type ErrToken struct { 42 + context string 43 + } 44 + 45 + func (err *ErrToken) Error() string { 46 + return "invalid email token: " + err.context 47 + } 48 + 49 + func (err *ErrToken) Unwrap() error { 50 + return util.ErrInvalidArgument 51 + } 52 + 53 + // CreateToken creates a token for the action/user tuple 54 + func CreateToken(ht HandlerType, user *user_model.User, data []byte) (string, error) { 55 + payload, err := util.PackData( 56 + time.Now().AddDate(tokenLifetimeInYears, 0, 0).Unix(), 57 + ht, 58 + data, 59 + ) 60 + if err != nil { 61 + return "", err 62 + } 63 + 64 + packagedData, err := util.PackData( 65 + user.ID, 66 + generateHmac([]byte(user.Rands), payload), 67 + payload, 68 + ) 69 + if err != nil { 70 + return "", err 71 + } 72 + 73 + return encodingWithoutPadding.EncodeToString(append([]byte{tokenVersion1}, packagedData...)), nil 74 + } 75 + 76 + // ExtractToken extracts the action/user tuple from the token and verifies the content 77 + func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.User, []byte, error) { 78 + data, err := encodingWithoutPadding.DecodeString(token) 79 + if err != nil { 80 + return UnknownHandlerType, nil, nil, err 81 + } 82 + 83 + if len(data) < 1 { 84 + return UnknownHandlerType, nil, nil, &ErrToken{"no data"} 85 + } 86 + 87 + if data[0] != tokenVersion1 { 88 + return UnknownHandlerType, nil, nil, &ErrToken{fmt.Sprintf("unsupported token version: %v", data[0])} 89 + } 90 + 91 + var userID int64 92 + var hmac []byte 93 + var payload []byte 94 + if err := util.UnpackData(data[1:], &userID, &hmac, &payload); err != nil { 95 + return UnknownHandlerType, nil, nil, err 96 + } 97 + 98 + user, err := user_model.GetUserByID(ctx, userID) 99 + if err != nil { 100 + return UnknownHandlerType, nil, nil, err 101 + } 102 + 103 + if !crypto_hmac.Equal(hmac, generateHmac([]byte(user.Rands), payload)) { 104 + return UnknownHandlerType, nil, nil, &ErrToken{"verification failed"} 105 + } 106 + 107 + var expiresUnix int64 108 + var handlerType HandlerType 109 + var innerPayload []byte 110 + if err := util.UnpackData(payload, &expiresUnix, &handlerType, &innerPayload); err != nil { 111 + return UnknownHandlerType, nil, nil, err 112 + } 113 + 114 + if time.Unix(expiresUnix, 0).Before(time.Now()) { 115 + return UnknownHandlerType, nil, nil, &ErrToken{"token expired"} 116 + } 117 + 118 + return handlerType, user, innerPayload, nil 119 + } 120 + 121 + // generateHmac creates a trunkated HMAC for the given payload 122 + func generateHmac(secret, payload []byte) []byte { 123 + mac := crypto_hmac.New(sha256.New, secret) 124 + mac.Write(payload) 125 + hmac := mac.Sum(nil) 126 + 127 + return hmac[:10] // RFC2104 recommends not using less then 80 bits 128 + }
+249
tests/integration/incoming_email_test.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package integration 5 + 6 + import ( 7 + "io" 8 + "net" 9 + "net/smtp" 10 + "strings" 11 + "testing" 12 + "time" 13 + 14 + "code.gitea.io/gitea/models/db" 15 + issues_model "code.gitea.io/gitea/models/issues" 16 + "code.gitea.io/gitea/models/unittest" 17 + user_model "code.gitea.io/gitea/models/user" 18 + "code.gitea.io/gitea/modules/setting" 19 + "code.gitea.io/gitea/services/mailer/incoming" 20 + incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" 21 + token_service "code.gitea.io/gitea/services/mailer/token" 22 + "code.gitea.io/gitea/tests" 23 + 24 + "github.com/stretchr/testify/assert" 25 + "gopkg.in/gomail.v2" 26 + ) 27 + 28 + func TestIncomingEmail(t *testing.T) { 29 + defer tests.PrepareTestEnv(t)() 30 + 31 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 32 + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) 33 + 34 + t.Run("Payload", func(t *testing.T) { 35 + defer tests.PrintCurrentTest(t)() 36 + 37 + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1}) 38 + 39 + _, err := incoming_payload.CreateReferencePayload(user) 40 + assert.Error(t, err) 41 + 42 + issuePayload, err := incoming_payload.CreateReferencePayload(issue) 43 + assert.NoError(t, err) 44 + commentPayload, err := incoming_payload.CreateReferencePayload(comment) 45 + assert.NoError(t, err) 46 + 47 + _, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, []byte{1, 2, 3}) 48 + assert.Error(t, err) 49 + 50 + ref, err := incoming_payload.GetReferenceFromPayload(db.DefaultContext, issuePayload) 51 + assert.NoError(t, err) 52 + assert.IsType(t, ref, new(issues_model.Issue)) 53 + assert.EqualValues(t, issue.ID, ref.(*issues_model.Issue).ID) 54 + 55 + ref, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, commentPayload) 56 + assert.NoError(t, err) 57 + assert.IsType(t, ref, new(issues_model.Comment)) 58 + assert.EqualValues(t, comment.ID, ref.(*issues_model.Comment).ID) 59 + }) 60 + 61 + t.Run("Token", func(t *testing.T) { 62 + defer tests.PrintCurrentTest(t)() 63 + 64 + payload := []byte{1, 2, 3, 4, 5} 65 + 66 + token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload) 67 + assert.NoError(t, err) 68 + assert.NotEmpty(t, token) 69 + 70 + ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token) 71 + assert.NoError(t, err) 72 + assert.Equal(t, token_service.ReplyHandlerType, ht) 73 + assert.Equal(t, user.ID, u.ID) 74 + assert.Equal(t, payload, p) 75 + }) 76 + 77 + t.Run("Handler", func(t *testing.T) { 78 + t.Run("Reply", func(t *testing.T) { 79 + t.Run("Comment", func(t *testing.T) { 80 + defer tests.PrintCurrentTest(t)() 81 + 82 + handler := &incoming.ReplyHandler{} 83 + 84 + payload, err := incoming_payload.CreateReferencePayload(issue) 85 + assert.NoError(t, err) 86 + 87 + assert.Error(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, nil, payload)) 88 + assert.NoError(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, user, payload)) 89 + 90 + content := &incoming.MailContent{ 91 + Content: "reply by mail", 92 + Attachments: []*incoming.Attachment{ 93 + { 94 + Name: "attachment.txt", 95 + Content: []byte("test"), 96 + }, 97 + }, 98 + } 99 + 100 + assert.NoError(t, handler.Handle(db.DefaultContext, content, user, payload)) 101 + 102 + comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{ 103 + IssueID: issue.ID, 104 + Type: issues_model.CommentTypeComment, 105 + }) 106 + assert.NoError(t, err) 107 + assert.NotEmpty(t, comments) 108 + comment := comments[len(comments)-1] 109 + assert.Equal(t, user.ID, comment.PosterID) 110 + assert.Equal(t, content.Content, comment.Content) 111 + assert.NoError(t, comment.LoadAttachments(db.DefaultContext)) 112 + assert.Len(t, comment.Attachments, 1) 113 + attachment := comment.Attachments[0] 114 + assert.Equal(t, content.Attachments[0].Name, attachment.Name) 115 + assert.EqualValues(t, 4, attachment.Size) 116 + }) 117 + 118 + t.Run("CodeComment", func(t *testing.T) { 119 + defer tests.PrintCurrentTest(t)() 120 + 121 + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 6}) 122 + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) 123 + 124 + handler := &incoming.ReplyHandler{} 125 + content := &incoming.MailContent{ 126 + Content: "code reply by mail", 127 + Attachments: []*incoming.Attachment{ 128 + { 129 + Name: "attachment.txt", 130 + Content: []byte("test"), 131 + }, 132 + }, 133 + } 134 + 135 + payload, err := incoming_payload.CreateReferencePayload(comment) 136 + assert.NoError(t, err) 137 + 138 + assert.NoError(t, handler.Handle(db.DefaultContext, content, user, payload)) 139 + 140 + comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{ 141 + IssueID: issue.ID, 142 + Type: issues_model.CommentTypeCode, 143 + }) 144 + assert.NoError(t, err) 145 + assert.NotEmpty(t, comments) 146 + comment = comments[len(comments)-1] 147 + assert.Equal(t, user.ID, comment.PosterID) 148 + assert.Equal(t, content.Content, comment.Content) 149 + assert.NoError(t, comment.LoadAttachments(db.DefaultContext)) 150 + assert.Empty(t, comment.Attachments) 151 + }) 152 + }) 153 + 154 + t.Run("Unsubscribe", func(t *testing.T) { 155 + defer tests.PrintCurrentTest(t)() 156 + 157 + watching, err := issues_model.CheckIssueWatch(user, issue) 158 + assert.NoError(t, err) 159 + assert.True(t, watching) 160 + 161 + handler := &incoming.UnsubscribeHandler{} 162 + 163 + content := &incoming.MailContent{ 164 + Content: "unsub me", 165 + } 166 + 167 + payload, err := incoming_payload.CreateReferencePayload(issue) 168 + assert.NoError(t, err) 169 + 170 + assert.NoError(t, handler.Handle(db.DefaultContext, content, user, payload)) 171 + 172 + watching, err = issues_model.CheckIssueWatch(user, issue) 173 + assert.NoError(t, err) 174 + assert.False(t, watching) 175 + }) 176 + }) 177 + 178 + if setting.IncomingEmail.Enabled { 179 + // This test connects to the configured email server and is currently only enabled for MySql integration tests. 180 + // It sends a reply to create a comment. If the comment is not detected after 10 seconds the test fails. 181 + t.Run("IMAP", func(t *testing.T) { 182 + defer tests.PrintCurrentTest(t)() 183 + 184 + payload, err := incoming_payload.CreateReferencePayload(issue) 185 + assert.NoError(t, err) 186 + token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload) 187 + assert.NoError(t, err) 188 + 189 + msg := gomail.NewMessage() 190 + msg.SetHeader("To", strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)) 191 + msg.SetHeader("From", user.Email) 192 + msg.SetBody("text/plain", token) 193 + err = gomail.Send(&smtpTestSender{}, msg) 194 + assert.NoError(t, err) 195 + 196 + assert.Eventually(t, func() bool { 197 + comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{ 198 + IssueID: issue.ID, 199 + Type: issues_model.CommentTypeComment, 200 + }) 201 + assert.NoError(t, err) 202 + assert.NotEmpty(t, comments) 203 + 204 + comment := comments[len(comments)-1] 205 + 206 + return comment.PosterID == user.ID && comment.Content == token 207 + }, 10*time.Second, 1*time.Second) 208 + }) 209 + } 210 + } 211 + 212 + // A simple SMTP mail sender used for integration tests. 213 + type smtpTestSender struct{} 214 + 215 + func (s *smtpTestSender) Send(from string, to []string, msg io.WriterTo) error { 216 + conn, err := net.Dial("tcp", net.JoinHostPort(setting.IncomingEmail.Host, "25")) 217 + if err != nil { 218 + return err 219 + } 220 + defer conn.Close() 221 + 222 + client, err := smtp.NewClient(conn, setting.IncomingEmail.Host) 223 + if err != nil { 224 + return err 225 + } 226 + 227 + if err = client.Mail(from); err != nil { 228 + return err 229 + } 230 + 231 + for _, rec := range to { 232 + if err = client.Rcpt(rec); err != nil { 233 + return err 234 + } 235 + } 236 + 237 + w, err := client.Data() 238 + if err != nil { 239 + return err 240 + } 241 + if _, err := msg.WriteTo(w); err != nil { 242 + return err 243 + } 244 + if err := w.Close(); err != nil { 245 + return err 246 + } 247 + 248 + return client.Quit() 249 + }
+10
tests/mysql.ini.tmpl
··· 124 124 125 125 [packages] 126 126 ENABLED = true 127 + 128 + [email.incoming] 129 + ENABLED = true 130 + HOST = smtpimap 131 + PORT = 993 132 + USERNAME = debug@localdomain.test 133 + PASSWORD = debug 134 + USE_TLS = true 135 + SKIP_TLS_VERIFY = true 136 + REPLY_TO_ADDRESS = incoming+%{token}@localhost