├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.md └── workflows │ ├── docker_build.yml │ ├── docker_build_pre.yml │ ├── release.yml │ └── unitTest.yml ├── .gitignore ├── Dockerfile ├── DockerfileGithubAction ├── LICENSE ├── Makefile ├── README.md ├── README_CN.md ├── docs ├── cn.gif ├── en.gif ├── nuisance │ └── demo.txt └── settings.jpg ├── fe ├── .eslintrc.cjs ├── .gitignore ├── .vscode │ └── extensions.json ├── README.md ├── index.html ├── package.json ├── public │ └── favicon.ico ├── src │ ├── App.vue │ ├── assets │ │ ├── base.css │ │ ├── logo.svg │ │ └── main.css │ ├── components │ │ ├── GroupSettings.vue │ │ ├── HomeAside.vue │ │ ├── HomeHeader.vue │ │ ├── PluginSettings.vue │ │ ├── RuleSettings.vue │ │ ├── SecuritySettings.vue │ │ └── UserManagement.vue │ ├── i18n │ │ └── i18n.js │ ├── main.js │ ├── router │ │ └── index.js │ ├── stores │ │ ├── group.js │ │ └── useGlobalStatusStore.js │ ├── utils │ │ └── axios.js │ └── views │ │ ├── EditerView.vue │ │ ├── EmailDetailView.vue │ │ ├── ListView.vue │ │ ├── LoginView.vue │ │ └── SetupView.vue ├── vite.config.js └── yarn.lock └── server ├── config ├── config.dev.json ├── config.go ├── config.json ├── config_mysql.json ├── dkim │ ├── README.md │ ├── dkim.priv │ └── dkim.public └── ssl │ ├── README.md │ ├── private.key │ ├── public.crt │ └── server.csr ├── consts └── consts.go ├── controllers ├── attachments.go ├── base.go ├── email │ ├── delete.go │ ├── detail.go │ ├── list.go │ ├── move.go │ ├── read.go │ └── send.go ├── group.go ├── interceptor.go ├── login.go ├── ping.go ├── plugin.go ├── rule.go ├── settings.go ├── setup.go └── user.go ├── db └── init.go ├── dto ├── parsemail │ ├── dkim.go │ ├── dkim_test.go │ ├── email.go │ ├── email_test.go │ └── encodedword.go ├── response │ ├── email.go │ └── response.go ├── rule.go └── tag.go ├── go.mod ├── go.sum ├── hooks ├── base.go ├── debug │ └── debug.go ├── framework │ └── framework.go ├── spam_block │ ├── README.md │ ├── export │ │ ├── Makefile │ │ └── export.go │ ├── requirements.txt │ ├── spam_block.go │ ├── static │ │ ├── index.html │ │ └── jquery.js │ ├── test.py │ ├── testData │ │ └── data.csv │ ├── tools │ │ └── tools.go │ ├── train.py │ ├── trainData │ │ └── data.csv │ ├── trec06c_format.py │ └── trec07p_format.py ├── telegram_push │ ├── README.md │ └── telegram_push.go └── wechat_push │ ├── README.md │ └── wechat_push.go ├── i18n └── i18n.go ├── listen ├── cron_server │ └── ssl_update.go ├── http_server │ ├── http_server.go │ ├── https_server.go │ └── setup_server.go ├── imap_server │ ├── imap_server.go │ ├── imap_server_test.go │ ├── server.go │ ├── session_copy.go │ ├── session_create.go │ ├── session_delete.go │ ├── session_expunge.go │ ├── session_fetch.go │ ├── session_idle.go │ ├── session_list.go │ ├── session_login.go │ ├── session_move.go │ ├── session_namespace.go │ ├── session_poll.go │ ├── session_rename.go │ ├── session_search.go │ ├── session_select.go │ ├── session_status.go │ └── session_store.go ├── pop3_server │ ├── action.go │ ├── action_test.go │ └── pop3server.go └── smtp_server │ ├── action.go │ ├── login.go │ ├── read_content.go │ ├── read_content_test.go │ ├── smtp.go │ └── smtp_test │ └── sendEmailTest.py ├── main.go ├── main_test.go ├── models ├── User.go ├── email.go ├── group.go ├── rule.go ├── session.go ├── user_email.go └── version.go ├── res_init └── init.go ├── services ├── attachments │ └── attachments.go ├── auth │ └── auth.go ├── del_email │ └── del_email.go ├── detail │ └── detail.go ├── group │ ├── group.go │ └── group_test.go ├── list │ ├── list.go │ └── list_test.go ├── rule │ ├── match │ │ ├── base.go │ │ ├── contains_match.go │ │ ├── equal_match.go │ │ ├── regex_match.go │ │ └── regex_match_test.go │ └── rule.go └── setup │ ├── db.go │ ├── dns.go │ ├── domain.go │ ├── finish.go │ └── ssl │ ├── challenge.go │ ├── dnsProvide.go │ ├── ssl.go │ └── ssl_test.go ├── session └── init.go ├── signal └── signal.go └── utils ├── address ├── address.go └── address_test.go ├── array └── array.go ├── async └── async.go ├── consts └── consts.go ├── context └── context.go ├── errors └── error.go ├── file └── file.go ├── id └── logid.go ├── ip └── ip.go ├── password ├── encode.go └── encode_test.go ├── send └── send.go ├── smtp └── smtp.go ├── utf7 ├── LICENSE ├── README.md ├── decoder.go ├── decoder_test.go ├── encoder.go ├── encoder_test.go └── utf7.go └── version ├── version.go └── version_test.go /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: bug反馈 / bug report 2 | description: "提交 bug反馈/ bug report" 3 | body: 4 | - type: checkboxes 5 | attributes: 6 | label: 完整性要求 / Integrity requirements 7 | description: |- 8 | 请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。 9 | options: 10 | - label: 我保证阅读了文档,了解所有我编写的配置文件项的含义,而不是大量堆砌看似有用的选项或默认值。 11 | required: true 12 | - label: 我提供了完整的配置文件和日志,而不是出于自己的判断只给出截取的部分。 13 | required: true 14 | - label: 我搜索了issues,没有发现已提出的类似问题。 15 | required: true 16 | - label: 我已经阅读了项目[Readme](https://github.com/Jinnrry/PMail/blob/master/README_CN.md)和[常见问题](https://github.com/Jinnrry/PMail/discussions/170) 17 | required: true 18 | - type: input 19 | attributes: 20 | label: 版本 21 | description: 使用的PMail版本 22 | validations: 23 | required: true 24 | - type: markdown 25 | attributes: 26 | value: |- 27 | ## 配置与日志部分 28 | 29 | ### 对于配置文件 30 | 请提供可以重现问题的配置文件。 31 | 32 | ### 对于日志 33 | 请先将日志等级设置为 debug. 34 | 重启 PMail ,再按复现方式操作,尽量减少日志中的无关部分。 35 | 记得删除有关个人信息的部分。 36 | 提供 PMail 的日志,而不是面板或者别的东西输出的日志。 37 | 38 | ### 最后 39 | 在去掉不影响复现的部分后,提供实际运行的**完整**文件,不要出于自己的判断只提供入站出站或者几行日志。 40 | 把内容放在文本框预置的 ```
``` 和 ```
``` 中间。 41 | 如果问题十分明确只出现在某一端(如按文档正确编写配置后核心启动失败/崩溃),可以在下面不需要的项目填入N/A. 42 | - type: textarea 43 | attributes: 44 | label: 服务端配置 45 | value: |- 46 |

47 | 
48 |         
49 | validations: 50 | required: true 51 | - type: textarea 52 | attributes: 53 | label: 服务端日志 54 | value: |- 55 |

56 | 
57 |         
58 | validations: 59 | required: true 60 | - type: textarea 61 | attributes: 62 | label: 描述 63 | description: 请提供错误的详细描述。以及你认为有价值的信息。 64 | validations: 65 | required: true 66 | - type: textarea 67 | attributes: 68 | label: 重现方式 69 | description: |- 70 | 基于你下面提供的配置,提供重现BUG方法。 71 | validations: 72 | required: true 73 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: 部署错误及使用讨论 / Community Support and Questions 3 | url: https://github.com/Jinnrry/PMail/discussions 4 | about: 非程序异常请前往Discussions讨论 / Please ask and answer questions there. The issue tracker is for issues with core. 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 请完整读完[README](https://github.com/Jinnrry/PMail/blob/master/README_CN.md)再提问! / Before asking questions, please read the [README](https://github.com/Jinnrry/PMail/blob/master/README.md) ! 11 | -------------------------------------------------------------------------------- /.github/workflows/docker_build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [released] 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | 11 | jobs: 12 | build-and-push-image: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | steps: 18 | - name: Get version 19 | id: get_version 20 | run: | 21 | echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> ${GITHUB_ENV} 22 | echo "${GITHUB_REF/refs\/tags\//}" 23 | echo "${GITHUB_REF#refs/*/}" 24 | echo "${GITHUB_REF}" 25 | - uses: actions/checkout@v3 26 | 27 | - name: set lower case repository name 28 | run: | 29 | echo "REPOSITORY_LC=${REPOSITORY,,}" >> ${GITHUB_ENV} 30 | env: 31 | REPOSITORY: '${{ github.repository }}' 32 | 33 | - name: Set up QEMU 34 | uses: docker/setup-qemu-action@v3 35 | 36 | - name: Set up Docker Buildx 37 | uses: docker/setup-buildx-action@v3 38 | 39 | - uses: actions/setup-node@v4 40 | 41 | - name: Build FE 42 | run: | 43 | echo "$(git show -s --format=%H)" 44 | echo "GITHASH=$(git show -s --format=%H)" >> ${GITHUB_ENV} 45 | make build_fe 46 | 47 | - name: Log in to the Container registry 48 | uses: docker/login-action@v2.1.0 49 | with: 50 | registry: ${{ env.REGISTRY }} 51 | username: ${{ github.actor }} 52 | password: ${{ secrets.GITHUB_TOKEN }} 53 | 54 | - name: Build and push Docker images 55 | uses: docker/build-push-action@v4 56 | with: 57 | build-args: | 58 | VERSION=${{ env.VERSION }} 59 | GITHASH=${{ env.GITHASH }} 60 | context: . 61 | file: ./DockerfileGithubAction 62 | platforms: | 63 | linux/386 64 | linux/amd64 65 | linux/arm/v7 66 | linux/arm64 67 | push: true 68 | tags: | 69 | ${{ env.REGISTRY }}/${{ env.REPOSITORY_LC }}:${{ env.VERSION }} 70 | ${{ env.REGISTRY }}/${{ env.REPOSITORY_LC }}:latest 71 | -------------------------------------------------------------------------------- /.github/workflows/docker_build_pre.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI Pre 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [ prereleased ] 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | 11 | jobs: 12 | build-and-push-image: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | steps: 18 | - name: Get version 19 | id: get_version 20 | run: | 21 | echo "VERSION=pre${GITHUB_REF/refs\/tags\//}" >> ${GITHUB_ENV} 22 | echo "${GITHUB_REF/refs\/tags\//}" 23 | echo "${GITHUB_REF#refs/*/}" 24 | echo "${GITHUB_REF}" 25 | - uses: actions/checkout@v3 26 | 27 | - name: set lower case repository name 28 | run: | 29 | echo "REPOSITORY_LC=${REPOSITORY,,}" >> ${GITHUB_ENV} 30 | env: 31 | REPOSITORY: '${{ github.repository }}' 32 | 33 | - name: Set up QEMU 34 | uses: docker/setup-qemu-action@v3 35 | 36 | - name: Set up Docker Buildx 37 | uses: docker/setup-buildx-action@v3 38 | 39 | - uses: actions/setup-node@v4 40 | 41 | - name: Build FE 42 | run: | 43 | echo "GITHASH=$(git show -s --format=%H)" >> ${GITHUB_ENV} 44 | make build_fe 45 | 46 | - name: Log in to the Container registry 47 | uses: docker/login-action@v2.1.0 48 | with: 49 | registry: ${{ env.REGISTRY }} 50 | username: ${{ github.actor }} 51 | password: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | - name: Build and push Docker images 54 | uses: docker/build-push-action@v4 55 | with: 56 | build-args: | 57 | VERSION=${{ env.VERSION }} 58 | GITHASH=${{ env.GITHASH }} 59 | context: . 60 | file: ./DockerfileGithubAction 61 | platforms: | 62 | linux/386 63 | linux/amd64 64 | linux/arm/v7 65 | linux/arm64 66 | push: true 67 | tags: | 68 | ${{ env.REGISTRY }}/${{ env.REPOSITORY_LC }}:${{ env.VERSION }} 69 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [ released,prereleased ] 7 | 8 | 9 | jobs: 10 | build: 11 | permissions: 12 | contents: write 13 | strategy: 14 | matrix: 15 | goos: [ windows, linux, darwin ] 16 | goarch: [ amd64, arm64 ] 17 | runs-on: ubuntu-latest 18 | env: 19 | CGO_ENABLED: 0 20 | GOOS: ${{ matrix.goos }} 21 | GOARCH: ${{ matrix.goarch }} 22 | steps: 23 | - name: Get version 24 | id: get_version 25 | run: | 26 | echo "VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 27 | echo "${GITHUB_REF}" 28 | - name: Checkout 29 | uses: actions/checkout@v4.0.0 30 | - name: Setup Node.js environment 31 | uses: actions/setup-node@v3.8.1 32 | - name: Install Dependencies 33 | run: npm install --global yarn 34 | - name: Setup Go environment 35 | uses: actions/setup-go@v4.1.0 36 | with: 37 | check-latest: true 38 | - name: Gen output name 39 | run: | 40 | echo "FILENAME=pmail_${{ matrix.goos }}_${{ matrix.goarch }}" >> ${GITHUB_ENV} 41 | echo "TGFILENAME=telegram_push_${{ matrix.goos }}_${{ matrix.goarch }}" >> ${GITHUB_ENV} 42 | echo "WCFILENAME=wechat_push_${{ matrix.goos }}_${{ matrix.goarch }}" >> ${GITHUB_ENV} 43 | echo "SPAMBLOCKFILENAME=spam_block_${{ matrix.goos }}_${{ matrix.goarch }}" >> ${GITHUB_ENV} 44 | echo "ZIPNAME=${{ matrix.goos }}_${{ matrix.goarch }}" >> ${GITHUB_ENV} 45 | - name: Rename Windows File 46 | if: matrix.goos == 'windows' 47 | run: | 48 | echo "FILENAME=pmail_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV} 49 | echo "TGFILENAME=telegram_push_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV} 50 | echo "WCFILENAME=wechat_push_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV} 51 | echo "SPAMBLOCKFILENAME=spam_block_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV} 52 | - name: FE Build 53 | run: cd fe && yarn && yarn build 54 | - name: BE Build 55 | run: | 56 | cd server && cp -rf ../fe/dist listen/http_server 57 | go build -ldflags "-s -w -X 'main.version=${{ env.VERSION }}' -X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o ${{ env.FILENAME }} main.go 58 | go build -ldflags "-s -w" -o ${{ env.TGFILENAME }} hooks/telegram_push/telegram_push.go 59 | go build -ldflags "-s -w" -o ${{ env.WCFILENAME }} hooks/wechat_push/wechat_push.go 60 | go build -ldflags "-s -w" -o ${{ env.SPAMBLOCKFILENAME }} hooks/spam_block/spam_block.go 61 | ls -alh 62 | - name: Zip 63 | run: | 64 | cd ./server 65 | mkdir plugins 66 | mv ${{ env.TGFILENAME }} plugins/ 67 | mv ${{ env.WCFILENAME }} plugins/ 68 | mv ${{ env.SPAMBLOCKFILENAME }} plugins/ 69 | zip -r ${{ env.ZIPNAME }}.zip ${{ env.FILENAME }} plugins 70 | ls 71 | - name: Upload files to Artifacts 72 | uses: actions/upload-artifact@v4 73 | with: 74 | name: ${{ env.ZIPNAME }} 75 | path: server/${{ env.ZIPNAME }}.zip 76 | - name: Upload binaries to release 77 | uses: svenstaro/upload-release-action@v2 78 | with: 79 | repo_token: ${{ secrets.GITHUB_TOKEN }} 80 | file: server/${{ env.ZIPNAME }}.zip 81 | tag: ${{ github.ref }} 82 | file_glob: true 83 | -------------------------------------------------------------------------------- /.github/workflows/unitTest.yml: -------------------------------------------------------------------------------- 1 | name: PR - Docker unit test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | types: [opened, reopened, synchronize, edited] 8 | # Please, always create a pull request instead of push to master. 9 | 10 | permissions: 11 | contents: read 12 | pull-requests: write 13 | 14 | concurrency: 15 | group: docker-test-${{ github.ref_name }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | test: 20 | name: Docker tests 21 | runs-on: ubuntu-latest 22 | services: 23 | mysql: 24 | image: mysql 25 | env: 26 | MYSQL_DATABASE: pmail 27 | MYSQL_ROOT_PASSWORD: githubTest 28 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 29 | postgres: 30 | image: postgres 31 | env: 32 | POSTGRESQL_PASSWORD: githubTest 33 | container: 34 | image: golang 35 | env: 36 | REPOSITORY: ${{ github.repository }} 37 | TRIGGERING_ACTOR: ${{ github.triggering_actor }} 38 | SOURCE_BRANCH: ${{ github.ref_name }} 39 | COMMIT: ${{ github.workflow_sha }} 40 | EVENT: ${{ github.event_name}} 41 | steps: 42 | - name: Setup Node.js environment 43 | run: apt update && apt install -y nodejs npm 44 | 45 | - name: Checkout 46 | uses: actions/checkout@v4 47 | 48 | - name: Pull Request Labeler 49 | if: ${{ failure() }} 50 | uses: actions-cool/issues-helper@v3 51 | with: 52 | actions: 'add-labels' 53 | token: ${{ secrets.GITHUB_TOKEN }} 54 | issue-number: ${{ github.event.pull_request.number }} 55 | labels: 'Auto: Test Failed' 56 | 57 | - name: Install Dependencies 58 | run: npm install --global yarn 59 | 60 | - name: FE build 61 | run: make build_fe 62 | 63 | - name: Run Test Mysql 64 | run: make test_mysql 65 | 66 | - name: Run Test 67 | run: make test 68 | 69 | # - name: Run postgres 70 | # run: make test_postgres 71 | 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | dist 4 | output 5 | pmail.db 6 | server/plugins 7 | config 8 | *_KEY 9 | AURORA_SECRET -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine as febuild 2 | WORKDIR /work 3 | 4 | COPY fe . 5 | 6 | RUN yarn && yarn build 7 | 8 | 9 | FROM golang:alpine as serverbuild 10 | ARG VERSION 11 | WORKDIR /work 12 | COPY . . 13 | COPY --from=febuild /work/dist /work/server/listen/http_server/dist 14 | RUN apk update && apk add git 15 | RUN cd /work/server && go build -ldflags "-s -w -X 'main.version=${VERSION}' -X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail main.go 16 | RUN cd /work/server/hooks/telegram_push && go build -ldflags "-s -w" -o output/telegram_push telegram_push.go 17 | RUN cd /work/server/hooks/wechat_push && go build -ldflags "-s -w" -o output/wechat_push wechat_push.go 18 | RUN cd /work/server/hooks/spam_block && go build -ldflags "-s -w" -o output/spam_block spam_block.go 19 | 20 | 21 | FROM alpine 22 | 23 | WORKDIR /work 24 | 25 | # 设置时区 26 | RUN apk add --no-cache tzdata \ 27 | && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ 28 | && echo "Asia/Shanghai" > /etc/timezone \ 29 | &&rm -rf /var/cache/apk/* /tmp/* /var/tmp/* $HOME/.cache 30 | 31 | 32 | COPY --from=serverbuild /work/server/pmail . 33 | COPY --from=serverbuild /work/server/hooks/telegram_push/output/* ./plugins/ 34 | COPY --from=serverbuild /work/server/hooks/wechat_push/output/* ./plugins/ 35 | COPY --from=serverbuild /work/server/hooks/spam_block/output/* ./plugins/ 36 | 37 | EXPOSE 25 80 110 443 465 587 995 993 38 | 39 | CMD /work/pmail 40 | -------------------------------------------------------------------------------- /DockerfileGithubAction: -------------------------------------------------------------------------------- 1 | FROM golang:alpine as serverbuild 2 | ARG VERSION 3 | ARG GITHASH 4 | WORKDIR /work 5 | 6 | COPY server . 7 | 8 | RUN apk update && apk add git 9 | RUN go build -ldflags "-s -w -X 'main.version=${VERSION}' -X 'main.goVersion=$(go version)' -X 'main.gitHash=${GITHASH}' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail main.go 10 | RUN cd /work/hooks/telegram_push && go build -ldflags "-s -w" -o output/telegram_push telegram_push.go 11 | RUN cd /work/hooks/wechat_push && go build -ldflags "-s -w" -o output/wechat_push wechat_push.go 12 | RUN cd /work/hooks/spam_block && go build -ldflags "-s -w" -o output/spam_block spam_block.go 13 | 14 | 15 | FROM alpine 16 | 17 | WORKDIR /work 18 | 19 | # 设置时区 20 | RUN apk add --no-cache tzdata \ 21 | && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ 22 | && echo "Asia/Shanghai" > /etc/timezone \ 23 | &&rm -rf /var/cache/apk/* /tmp/* /var/tmp/* $HOME/.cache 24 | 25 | 26 | COPY --from=serverbuild /work/pmail . 27 | COPY --from=serverbuild /work/hooks/telegram_push/output/* ./plugins/ 28 | COPY --from=serverbuild /work/hooks/wechat_push/output/* ./plugins/ 29 | COPY --from=serverbuild /work/hooks/spam_block/output/* ./plugins/ 30 | 31 | EXPOSE 25 80 110 443 465 587 995 993 32 | 33 | CMD /work/pmail 34 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: build_fe build_server telegram_push web_push wechat_push package 2 | 3 | clean: 4 | rm -rf output 5 | 6 | 7 | build_fe: 8 | cd fe && yarn && yarn build 9 | rm -rf server/listen/http_server/dist 10 | cd server && cp -rf ../fe/dist listen/http_server 11 | 12 | build_server: 13 | cd server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail_linux_amd64 main.go 14 | cd server && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail_windows_amd64.exe main.go 15 | cd server && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w -X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail_mac_amd64 main.go 16 | cd server && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w -X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail_mac_arm64 main.go 17 | 18 | telegram_push: 19 | cd server/hooks/telegram_push && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o output/telegram_push_linux_amd64 telegram_push.go 20 | cd server/hooks/telegram_push && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o output/telegram_push_windows_amd64.exe telegram_push.go 21 | cd server/hooks/telegram_push && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o output/telegram_push_mac_amd64 telegram_push.go 22 | cd server/hooks/telegram_push && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o output/telegram_push_mac_arm64 telegram_push.go 23 | 24 | 25 | wechat_push: 26 | cd server/hooks/wechat_push && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o output/wechat_push_linux_amd64 wechat_push.go 27 | cd server/hooks/wechat_push && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o output/wechat_push_windows_amd64.exe wechat_push.go 28 | cd server/hooks/wechat_push && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o output/wechat_push_mac_amd64 wechat_push.go 29 | cd server/hooks/wechat_push && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o output/wechat_push_mac_arm64 wechat_push.go 30 | 31 | spam_block: 32 | cd server/hooks/spam_block && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o output/spam_block_linux_amd64 spam_block.go 33 | cd server/hooks/spam_block && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o output/spam_block_windows_amd64.exe spam_block.go 34 | cd server/hooks/spam_block && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o output/spam_block_mac_amd64 spam_block.go 35 | cd server/hooks/spam_block && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o output/spam_block_mac_arm64 spam_block.go 36 | 37 | 38 | 39 | plugin: telegram_push wechat_push 40 | 41 | 42 | package: clean 43 | mkdir output 44 | mv server/pmail* output/ 45 | mkdir output/config 46 | mkdir output/plugins 47 | cp -r server/config/dkim output/config/ 48 | cp -r server/config/ssl output/config/ 49 | cp -r server/config/config.json output/config/ 50 | mv server/hooks/telegram_push/output/* output/plugins 51 | mv server/hooks/wechat_push/output/* output/plugins 52 | cp README.md output/ 53 | 54 | test: 55 | export setup_port=17888 && cd server && export PMail_ROOT=$(CURDIR)/server/ && go test -v -p 1 ./... 56 | 57 | test_mysql: 58 | export setup_port=17888 && cd server && export PMail_ROOT=$(CURDIR)/server/ && go test -args "mysql" -v -p 1 ./... 59 | 60 | test_postgres: 61 | export setup_port=17888 && cd server && export PMail_ROOT=$(CURDIR)/server/ && go test -args "postgres" -v -p 1 ./... -------------------------------------------------------------------------------- /docs/cn.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jinnrry/PMail/4504bdb4900584d532771223f8c0d9d2b36e99da/docs/cn.gif -------------------------------------------------------------------------------- /docs/en.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jinnrry/PMail/4504bdb4900584d532771223f8c0d9d2b36e99da/docs/en.gif -------------------------------------------------------------------------------- /docs/settings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jinnrry/PMail/4504bdb4900584d532771223f8c0d9d2b36e99da/docs/settings.jpg -------------------------------------------------------------------------------- /fe/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | root: true, 4 | 'extends': [ 5 | 'plugin:vue/vue3-essential', 6 | 'eslint:recommended' 7 | ], 8 | parserOptions: { 9 | ecmaVersion: 'latest' 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /fe/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /fe/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /fe/README.md: -------------------------------------------------------------------------------- 1 | # fe 2 | 3 | 前端代码库 4 | 5 | ```sh 6 | yarn 7 | ``` 8 | 9 | ### Compile and Hot-Reload for Development 10 | 11 | ```sh 12 | yarn dev 13 | ``` 14 | 15 | ### Compile and Minify for Production 16 | 17 | ```sh 18 | yarn build 19 | ``` 20 | 21 | ### Lint with [ESLint](https://eslint.org/) 22 | 23 | ```sh 24 | yarn lint 25 | ``` 26 | -------------------------------------------------------------------------------- /fe/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | PMail 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /fe/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fe", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore" 10 | }, 11 | "dependencies": { 12 | "@wangeditor/editor": "^5.1.23", 13 | "@wangeditor/editor-for-vue": "^5.1.12", 14 | "axios": "^1.4.0", 15 | "element-plus": "^2.3.6", 16 | "pinia": "^2.0.36", 17 | "vue": "^3.3.2", 18 | "vue-icons-plus": "^0.1.6", 19 | "vue-router": "^4.2.0" 20 | }, 21 | "devDependencies": { 22 | "@vitejs/plugin-vue": "^4.2.3", 23 | "eslint": "^8.39.0", 24 | "eslint-plugin-vue": "^9.11.0", 25 | "unplugin-auto-import": "^0.16.4", 26 | "unplugin-vue-components": "^0.25.0", 27 | "vite": "^4.3.5", 28 | "vue-tsc": "^2.1.6" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /fe/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jinnrry/PMail/4504bdb4900584d532771223f8c0d9d2b36e99da/fe/public/favicon.ico -------------------------------------------------------------------------------- /fe/src/App.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 35 | 36 | 37 | 58 | -------------------------------------------------------------------------------- /fe/src/assets/base.css: -------------------------------------------------------------------------------- 1 | /* color palette from */ 2 | :root { 3 | --vt-c-white: #ffffff; 4 | --vt-c-white-soft: #f8f8f8; 5 | --vt-c-white-mute: #f2f2f2; 6 | 7 | --vt-c-black: #181818; 8 | --vt-c-black-soft: #222222; 9 | --vt-c-black-mute: #282828; 10 | 11 | --vt-c-indigo: #2c3e50; 12 | 13 | --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); 14 | --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); 15 | --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); 16 | --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); 17 | 18 | --vt-c-text-light-1: var(--vt-c-indigo); 19 | --vt-c-text-light-2: rgba(60, 60, 60, 0.66); 20 | --vt-c-text-dark-1: var(--vt-c-white); 21 | --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); 22 | } 23 | 24 | /* semantic color variables for this project */ 25 | :root { 26 | --color-background: var(--vt-c-white); 27 | --color-background-soft: var(--vt-c-white-soft); 28 | --color-background-mute: var(--vt-c-white-mute); 29 | 30 | --color-border: var(--vt-c-divider-light-2); 31 | --color-border-hover: var(--vt-c-divider-light-1); 32 | 33 | --color-heading: var(--vt-c-text-light-1); 34 | --color-text: var(--vt-c-text-light-1); 35 | 36 | --section-gap: 160px; 37 | } 38 | 39 | @media (prefers-color-scheme: dark) { 40 | :root { 41 | --color-background: var(--vt-c-black); 42 | --color-background-soft: var(--vt-c-black-soft); 43 | --color-background-mute: var(--vt-c-black-mute); 44 | 45 | --color-border: var(--vt-c-divider-dark-2); 46 | --color-border-hover: var(--vt-c-divider-dark-1); 47 | 48 | --color-heading: var(--vt-c-text-dark-1); 49 | --color-text: var(--vt-c-text-dark-2); 50 | } 51 | } 52 | 53 | *, 54 | *::before, 55 | *::after { 56 | box-sizing: border-box; 57 | margin: 0; 58 | font-weight: normal; 59 | } 60 | 61 | html{ 62 | height: 100vh; 63 | } 64 | 65 | body { 66 | height: 100vh; 67 | min-height: 100vh; 68 | color: var(--color-text); 69 | background: var(--color-background); 70 | transition: color 0.5s, background-color 0.5s; 71 | line-height: 1.6; 72 | font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, 73 | Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 74 | font-size: 15px; 75 | text-rendering: optimizeLegibility; 76 | -webkit-font-smoothing: antialiased; 77 | -moz-osx-font-smoothing: grayscale; 78 | } 79 | -------------------------------------------------------------------------------- /fe/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fe/src/assets/main.css: -------------------------------------------------------------------------------- 1 | @import './base.css'; 2 | 3 | #app { 4 | margin: 0 auto; 5 | padding: 0; 6 | height: 100vh; 7 | font-weight: normal; 8 | font-family: Avenir, Helvetica, Arial, sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | text-align: center; 12 | color: #2c3e50; 13 | } 14 | -------------------------------------------------------------------------------- /fe/src/components/GroupSettings.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 123 | -------------------------------------------------------------------------------- /fe/src/components/HomeAside.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 37 | 38 | -------------------------------------------------------------------------------- /fe/src/components/HomeHeader.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /fe/src/components/PluginSettings.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 27 | 28 | -------------------------------------------------------------------------------- /fe/src/components/SecuritySettings.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | -------------------------------------------------------------------------------- /fe/src/main.js: -------------------------------------------------------------------------------- 1 | import './assets/main.css' 2 | 3 | import { createApp } from 'vue' 4 | import { createPinia } from 'pinia' 5 | import App from './App.vue' 6 | import {router} from './router' 7 | 8 | import ElementPlus from 'element-plus' 9 | import 'element-plus/dist/index.css' 10 | 11 | const app = createApp(App) 12 | app.use(router) 13 | app.use(createPinia()) 14 | app.use(ElementPlus) 15 | app.mount('#app') 16 | -------------------------------------------------------------------------------- /fe/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory } from 'vue-router' 2 | import ListView from '../views/ListView.vue' 3 | import EditerView from '../views/EditerView.vue' 4 | import LoginView from '../views/LoginView.vue' 5 | import EmailDetailView from '../views/EmailDetailView.vue' 6 | import SetupView from '../views/SetupView.vue' 7 | 8 | 9 | const router = createRouter({ 10 | history: createWebHashHistory(import.meta.env.BASE_URL), 11 | routes: [ 12 | { 13 | path: '/', 14 | name: 'home', 15 | component: ListView 16 | }, 17 | { 18 | path: '/list', 19 | name: 'list', 20 | component: ListView 21 | }, 22 | { 23 | path: '/editer', 24 | name: "editer", 25 | component: EditerView 26 | }, 27 | { 28 | path: '/login', 29 | name: "login", 30 | component: LoginView 31 | }, 32 | { 33 | path: '/setup', 34 | name: "setup", 35 | component: SetupView 36 | }, 37 | { 38 | path: '/detail/:id', 39 | name: "detail", 40 | component: EmailDetailView 41 | } 42 | ] 43 | }) 44 | 45 | 46 | 47 | 48 | 49 | export {router}; 50 | -------------------------------------------------------------------------------- /fe/src/stores/group.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | import { defineStore } from 'pinia' 3 | import lang from '../i18n/i18n'; 4 | 5 | const useGroupStore = defineStore('group', () => { 6 | const tag = ref("") 7 | const name = ref(lang.inbox) 8 | return { tag, name } 9 | }) 10 | 11 | export default useGroupStore -------------------------------------------------------------------------------- /fe/src/stores/useGlobalStatusStore.js: -------------------------------------------------------------------------------- 1 | import {defineStore} from "pinia"; 2 | import {http} from "@/utils/axios"; 3 | 4 | const useGlobalStatusStore = defineStore('useGlobalStatusStore', { 5 | state() { 6 | return { 7 | userInfos: {} 8 | } 9 | }, 10 | getters: { 11 | isLogin(state) { 12 | return Object.keys(state.userInfos) !== 0 13 | } 14 | }, 15 | actions: { 16 | init(callback) { 17 | let that = this 18 | http.post("/api/user/info", {}).then(res => { 19 | if (res.errorNo === 0) { 20 | Object.assign(that.userInfos, res.data) 21 | console.log("userInfos") 22 | callback() 23 | } 24 | }) 25 | } 26 | } 27 | }) 28 | 29 | 30 | export {useGlobalStatusStore}; -------------------------------------------------------------------------------- /fe/src/utils/axios.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import lang from '../i18n/i18n'; 3 | import {useGlobalStatusStore} from "@/stores/useGlobalStatusStore"; 4 | import {router} from "@/router"; 5 | 6 | //创建axios的一个实例 7 | const http = axios.create({ 8 | baseURL: import.meta.env.VITE_APP_URL, //接口统一域名 9 | timeout: 60000, //设置超时 10 | headers: { 11 | 'Content-Type': 'application/json;charset=UTF-8;', 12 | 'Lang': lang.lang 13 | } 14 | }); 15 | 16 | //请求拦截器 17 | http.interceptors.request.use((config) => { 18 | //若请求方式为post,则将data参数转为JSON字符串 19 | if (config.method === 'POST') { 20 | config.data = JSON.stringify(config.data); 21 | } 22 | return config; 23 | }, (error) => 24 | // 对请求错误做些什么 25 | Promise.reject(error)); 26 | 27 | //响应拦截器 28 | http.interceptors.response.use(async (response) => { 29 | //响应成功 30 | if (response.data.errorNo === 403) { 31 | 32 | await router.replace({ 33 | path: '/login', 34 | query: { 35 | redirect: router.currentRoute.fullPath 36 | } 37 | }); 38 | } 39 | //响应成功 40 | if (response.data.errorNo === 402) { 41 | await router.replace({ 42 | path: '/setup', 43 | query: { 44 | redirect: router.currentRoute.fullPath 45 | } 46 | }); 47 | } 48 | return response.data; 49 | }, async (error) => { 50 | //响应错误 51 | if (error.response && error.response.status) { 52 | let message = "" 53 | switch (error.response.status) { 54 | case 400: 55 | message = '请求错误'; 56 | break; 57 | case 401: 58 | message = '请求错误'; 59 | break; 60 | case 403: 61 | await router.replace({ 62 | path: '/login', 63 | query: { 64 | redirect: router.currentRoute.fullPath 65 | } 66 | }); 67 | break; 68 | case 404: 69 | message = '请求地址出错'; 70 | break; 71 | case 408: 72 | message = '请求超时'; 73 | break; 74 | case 500: 75 | message = '服务器内部错误!'; 76 | break; 77 | case 501: 78 | message = '服务未实现!'; 79 | break; 80 | case 502: 81 | message = '网关错误!'; 82 | break; 83 | case 503: 84 | message = '服务不可用!'; 85 | break; 86 | case 504: 87 | message = '网关超时!'; 88 | break; 89 | case 505: 90 | message = 'HTTP版本不受支持'; 91 | break; 92 | default: 93 | // eslint-disable-next-line no-unused-vars 94 | message = '请求失败'; 95 | } 96 | return Promise.reject(error); 97 | } 98 | return Promise.reject(error); 99 | }); 100 | 101 | export {http}; -------------------------------------------------------------------------------- /fe/src/views/EmailDetailView.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 89 | 90 | -------------------------------------------------------------------------------- /fe/src/views/LoginView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /fe/vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | import { defineConfig } from 'vite' 3 | import AutoImport from 'unplugin-auto-import/vite' 4 | import Components from 'unplugin-vue-components/vite' 5 | import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' 6 | import vue from '@vitejs/plugin-vue' 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [ 11 | vue(), 12 | AutoImport({ 13 | resolvers: [ElementPlusResolver()], 14 | }), 15 | Components({ 16 | resolvers: [ElementPlusResolver()], 17 | }), 18 | ], 19 | resolve: { 20 | alias: { 21 | '@': fileURLToPath(new URL('./src', import.meta.url)) 22 | } 23 | }, 24 | server: { 25 | cors: true, 26 | proxy: { 27 | "/api": "http://127.0.0.1/", 28 | "/attachments":"http://127.0.0.1/" 29 | } 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /server/config/config.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "logLevel": "info", 3 | "domain": "domain.com", 4 | "webDomain": "mail.domain.com", 5 | "dkimPrivateKeyPath": "config/dkim/dkim.priv", 6 | "sslType": "0", 7 | "SSLPrivateKeyPath": "config/ssl/private.key", 8 | "SSLPublicKeyPath": "config/ssl/public.crt", 9 | "dbDSN": "./config/pmail.db", 10 | "dbType": "sqlite", 11 | "spamFilterLevel": 2, 12 | "httpPort": 80, 13 | "httpsPort": 443, 14 | "weChatPushAppId": "", 15 | "weChatPushSecret": "", 16 | "weChatPushTemplateId": "", 17 | "weChatPushUserId": "", 18 | "tgChatId": "", 19 | "tgBotToken": "", 20 | "webPushUrl": "", 21 | "webPushToken": "", 22 | "isInit": true, 23 | "httpsEnabled": 2 24 | } -------------------------------------------------------------------------------- /server/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "logLevel": "debug", 3 | "domain": "test.domain", 4 | "domains": null, 5 | "webDomain": "mail.test.domain", 6 | "dkimPrivateKeyPath": "config/dkim/dkim.priv", 7 | "sslType": "1", 8 | "SSLPrivateKeyPath": "./config/ssl/private.key", 9 | "SSLPublicKeyPath": "./config/ssl/public.crt", 10 | "dbDSN": "./config/pmail_temp.db", 11 | "dbType": "sqlite", 12 | "httpsEnabled": 2, 13 | "spamFilterLevel": 0, 14 | "httpPort": 80, 15 | "httpsPort": 443, 16 | "isInit": true 17 | } -------------------------------------------------------------------------------- /server/config/config_mysql.json: -------------------------------------------------------------------------------- 1 | { 2 | "logLevel": "info", 3 | "domain": "domain.com", 4 | "webDomain": "mail.domain.com", 5 | "dkimPrivateKeyPath": "config/dkim/dkim.priv", 6 | "sslType": "0", 7 | "SSLPrivateKeyPath": "config/ssl/private.key", 8 | "SSLPublicKeyPath": "config/ssl/public.crt", 9 | "dbDSN": "root:root@tcp(127.0.0.1:3306)/pmail?parseTime=True&loc=Local", 10 | "dbType": "mysql", 11 | "spamFilterLevel": 1, 12 | "httpPort": 80, 13 | "httpsPort": 443, 14 | "isInit": true, 15 | "httpsEnabled": 2 16 | } -------------------------------------------------------------------------------- /server/config/dkim/README.md: -------------------------------------------------------------------------------- 1 | 这里存储的秘钥仅开发测试使用,线上环境请重新生成! -------------------------------------------------------------------------------- /server/config/dkim/dkim.priv: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBANgOYlTcSbWqw54y 3 | ISRYq6keu/fPj/m7CiVaXtOc9bJhI5uBsd8Jz1eIRzNmqLITL2e5lz2vpQAmpk3z 4 | 8Qd2iGCbZBwMXcfPVhAasGH367GRnJw3QPybA0Maob8CnfKDH6Pks3bX7xeef/U3 5 | ufHYnwgPZUi121ECvPjZLEmXpBrvAgMBAAECgYAxdeGG4cMyBnyvy3QQ2Qe7OKD5 6 | Uxf3qJzi/jQ1J3qLsncvU1p/38QKmtUJ7Fd0JLY2faMk6P/R8AckU1L7TWRcpafY 7 | fUU4xpuTHpBnMhglOGjEoOfSUFh9iieG8cVreozOOfihFRgRUxu6zfxycymHjGxF 8 | WbdK1zoNLgFgM0DWEQJBAOnkLf4P+pDMCeA/60pll39gIuW+AlMpWrjuA3Yhdu4B 9 | BvC5ea3fIVCWiksfBKihXDZkLG9PZDipy2WiNypPx0kCQQDsep7qHiBPpcp4a9OU 10 | KXolG771iHODId2Nc3zez2xG+2pY5BzoHD2TFdW1/5v9d4q+6u6dUb8v/t2GrMIQ 11 | wrh3AkEAiO0Dm+wA1YoN8hGZjqlhArnmVDdjpwnbyc3Viu/Wb0l8par/uDGbkFFB 12 | Tu8uzAYDNPh6JwQEeUO2Bp7rysJ/uQJBAJKq7rsX2kRr+Gq9vaksHHS9g693ZOVU 13 | 8LuVgEIU9fwEXQ4q1P7k3Q/HwBe0JESNiwEkZsAt/l0/PpgTt/17N7sCQBQKcd7e 14 | ++RuJCiMc0vMSYAKAmiARJHv/YoxS4tLngtrhu0h5uhr+35c0kJvy6nX0VBb5KV8 15 | hUK3axAHTSO793o= 16 | -----END PRIVATE KEY----- 17 | -------------------------------------------------------------------------------- /server/config/dkim/dkim.public: -------------------------------------------------------------------------------- 1 | v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDYDmJU3Em1qsOeMiEkWKupHrv3z4/5uwolWl7TnPWyYSObgbHfCc9XiEczZqiyEy9nuZc9r6UAJqZN8/EHdohgm2QcDF3Hz1YQGrBh9+uxkZycN0D8mwNDGqG/Ap3ygx+j5LN21+8Xnn/1N7nx2J8ID2VItdtRArz42SxJl6Qa7wIDAQAB -------------------------------------------------------------------------------- /server/config/ssl/README.md: -------------------------------------------------------------------------------- 1 | 这里存储的秘钥仅开发测试使用,线上环境请重新生成! -------------------------------------------------------------------------------- /server/config/ssl/private.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEA9uf8Kl44pXTgJSzns6SvySW4IRXh+K1Vi9FF4NRk3BysM0kI 3 | ANFU+VmH7eV/Ql1F4Lrwzalset3QIt5qsBFFDA8me42mF2KYXqIVWvcSg+1MPPMk 4 | Upb9jEPJAEOtMVVWKPu7AntCcWcmNESgSNLXDt7Ok7/hQ93jhiUQzrev6O5jNVvs 5 | fiyQncfMuaHi3joRxojFfq454djswNy0DGswaN9qF+6uGgBgdwc7vOA81YzVDli3 6 | 3NCjwq/RjYu8fFJSZlWtvgYdCEoOe9qhknLzQAD3wCzdkkmIKZP/igg8dvhz71L+ 7 | 4NWqDhYe1zmkAXL6Y8CucpiW1FrCRYjpDXtk6wIDAQABAoIBAAa+Y1bM6AMs5Apf 8 | 5Zw0fVCjJRpSPK/MHDALcTso0fBpIBLuhbdwAEAnP90xjX5EieoPcRBM9leMw2iQ 9 | Zp2UeyxPJZ/uSIEPAlZjWu33HZxY2OI5Sd6vnRE9sLm/H3XffND1vy/cKf5q8NIw 10 | pagXiiQv1biXXxG5d8NsM79RqQ5Vlsg/ygKb4OtHkGlOFdn/AhvDuOVBsR0ucCdO 11 | qwL8qVI30pCeqAXt/3BdEmqN4LNckhyrEiwLUUgslgfkP3DVKyi3NpNI5sUYTd/i 12 | Ui6eoCbhHSErV+JkFJNWIFy1nWjVNaEmEh9ArYq98xv1Z1Ejn+NHB/LCEhOnIJak 13 | Vg31FRECgYEA++P6YSVTJaXw8NmjLVLiBIWZFK23/3C+m3dkN+Ye8YFMtvvUOLP2 14 | mFCUA6WiST8R4R0djymmN2+0Aobjv2Qxesv9QmuNeiswQ7A+MKdmUuCy3ai22H5c 15 | XDqKSs7JyXDMeBvPsrtCsYKVIMtago7nE/Ut//oHGgOu83e1dZiA1GUCgYEA+u8w 16 | mHKbs720lD4HaSllxDRgAejnlmtyOZUm63dKxYAmwMaw0f1Y0UBn88hBNFcVxba2 17 | CIEuz11MuhPGHh6esricmLryw8IPWzchuSZtxHu6OKggWFoCfGR/TxHqVqSLvSI3 18 | B+GrdMQ2EqdmhzKgWQCazRtRlSrisRbrF5kkdw8CgYAwz/EJOk5ukUWrpsE0W0dp 19 | UOplU3TAj3yga/aDzphYfJH9M7fgdR9oTNUiD8rvHsW8NgQwZgXL4F2lz7X6tNPR 20 | 1A3z/RuhfRURSOoES6xMizaeNb+ZHIORa9a4wHHiE3XMILeTDy7Rb1iuzjlv63lk 21 | KLMNU8pkhCo3DA+iBjeQ8QKBgQCTmKUoxiC3NFpG58VMIcFuCrB97xRo8YIaRJTD 22 | 40LjsGEa+sN+gFoBmrSKO7u+oYp45ONlVTbHWcWLnZ3mkXQfA194pl2srzSBHoiD 23 | cwsViwEZ2ipMTYUwzZvkUlFX7SkUck+UHzTOVarIhhZUZ37RWv2yruLprnPwXd6h 24 | 3r4IGQKBgQCTeZPaMFsuSzBc1twb6NAxEkPhcZXOAFC+xbGM/jcDx5MZqLAi9gVS 25 | ASuZcWLtLsmIkmzBWnZyFm4enSUhTUYW8qpoV+KDM4RH8yBjSvitrr9lToM7nnnX 26 | eauj/AgwH9D6pt3JtyPeqVOLeCo9LWSQBkKJKxdkPPIDaz4OlgRn3A== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /server/config/ssl/public.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDmTCCAoECFH0cnkhiRVka2ZAWxpjtD+JgCv2GMA0GCSqGSIb3DQEBCwUAMIGI 3 | MQswCQYDVQQGEwJDTjETMBEGA1UECAwKU29tZS1TdGF0ZTELMAkGA1UEBwwCQkox 4 | DjAMBgNVBAoMBVBNYWlsMQ4wDAYDVQQLDAVQTWFpbDEWMBQGA1UEAwwNKi50ZXN0 5 | LmRvbWFpbjEfMB0GCSqGSIb3DQEJARYQdGVzdEB0ZXN0LmRvbWFpbjAeFw0yNDA3 6 | MDUwODA5MTVaFw0zNDA3MDMwODA5MTVaMIGIMQswCQYDVQQGEwJDTjETMBEGA1UE 7 | CAwKU29tZS1TdGF0ZTELMAkGA1UEBwwCQkoxDjAMBgNVBAoMBVBNYWlsMQ4wDAYD 8 | VQQLDAVQTWFpbDEWMBQGA1UEAwwNKi50ZXN0LmRvbWFpbjEfMB0GCSqGSIb3DQEJ 9 | ARYQdGVzdEB0ZXN0LmRvbWFpbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 10 | ggEBAPbn/CpeOKV04CUs57Okr8kluCEV4fitVYvRReDUZNwcrDNJCADRVPlZh+3l 11 | f0JdReC68M2pbHrd0CLearARRQwPJnuNphdimF6iFVr3EoPtTDzzJFKW/YxDyQBD 12 | rTFVVij7uwJ7QnFnJjREoEjS1w7ezpO/4UPd44YlEM63r+juYzVb7H4skJ3HzLmh 13 | 4t46EcaIxX6uOeHY7MDctAxrMGjfahfurhoAYHcHO7zgPNWM1Q5Yt9zQo8Kv0Y2L 14 | vHxSUmZVrb4GHQhKDnvaoZJy80AA98As3ZJJiCmT/4oIPHb4c+9S/uDVqg4WHtc5 15 | pAFy+mPArnKYltRawkWI6Q17ZOsCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAIl8L 16 | nL+wICckIRTfO4K4+F+7UHgLI2iTYRic6Hsy3K0aPvZAdVJiOlz0qaEcce+bFzj7 17 | BZiHQWDgiPF4vNFqcmas4oFV+Au2K8AoYQFJrq+3dtiUaMStT7JkjNjci3C2NhPO 18 | U7Gjq2OJx6IrJr7ECr2SFW4Sstw/h+s/mBfNl2BbE7kmT1xu/lyxpIiT7bYgSk/l 19 | A3cJFE1JIoa7eUPpV4Kh0titwJnDYVfQmEeBNYivyeNwe4hiHtiZDamI6H7Wu95b 20 | ldRRiFELoVs0GCn/ttIaSFvGUPeahn9rTNUUkjp4Un0RxuQFx8umQYl50zt1GZ1X 21 | DICmWUwYYejqdXrrww== 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /server/config/ssl/server.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIICzjCCAbYCAQAwgYgxCzAJBgNVBAYTAkNOMRMwEQYDVQQIDApTb21lLVN0YXRl 3 | MQswCQYDVQQHDAJCSjEOMAwGA1UECgwFUE1haWwxDjAMBgNVBAsMBVBNYWlsMRYw 4 | FAYDVQQDDA0qLnRlc3QuZG9tYWluMR8wHQYJKoZIhvcNAQkBFhB0ZXN0QHRlc3Qu 5 | ZG9tYWluMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9uf8Kl44pXTg 6 | JSzns6SvySW4IRXh+K1Vi9FF4NRk3BysM0kIANFU+VmH7eV/Ql1F4Lrwzalset3Q 7 | It5qsBFFDA8me42mF2KYXqIVWvcSg+1MPPMkUpb9jEPJAEOtMVVWKPu7AntCcWcm 8 | NESgSNLXDt7Ok7/hQ93jhiUQzrev6O5jNVvsfiyQncfMuaHi3joRxojFfq454djs 9 | wNy0DGswaN9qF+6uGgBgdwc7vOA81YzVDli33NCjwq/RjYu8fFJSZlWtvgYdCEoO 10 | e9qhknLzQAD3wCzdkkmIKZP/igg8dvhz71L+4NWqDhYe1zmkAXL6Y8CucpiW1FrC 11 | RYjpDXtk6wIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAPSNiDkAGFhMWFzQns+a 12 | 6+ujoI8Lf7baN/LklEEMRV2xq5sonj72ZU4PJDAyNFVj+pCKDOH5mb0r5ceRKYx/ 13 | HOlXxYvDhvk3t9mAGrRwG3UhzbCSbIIcvdKbU4FDGaRIzrSsvcv9fUDnw5fKTL61 14 | IRDNewABlCncsUYfHrXeuMtqdiWyZMfYjiHDunCCo/FbrG70q9LjMHT4zHl9LV8T 15 | jnrQzX0UaxBgLYDPEJX+2fqaXObv1HHSWlgZ6Ov9eRRKN2oRkb+KIbaTnlQ7Z7Y9 16 | kzwla/WOKJDL5FA7275tVpC553+rglq/0Jy9Hiq71Sis9gnG8eTYsghN5FHZSMFT 17 | a1s= 18 | -----END CERTIFICATE REQUEST----- 19 | -------------------------------------------------------------------------------- /server/consts/consts.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | const ( 4 | // EmailTypeSend 发信 5 | EmailTypeSend int8 = 1 6 | // EmailTypeReceive 收信 7 | EmailTypeReceive int8 = 0 8 | 9 | //EmailStatusWait 0未发送 10 | EmailStatusWait int8 = 0 11 | 12 | //EmailStatusSent 1已发送 13 | EmailStatusSent int8 = 1 14 | 15 | //EmailStatusFail 2发送失败 16 | EmailStatusFail int8 = 2 17 | 18 | //EmailStatusDel 3删除 19 | EmailStatusDel int8 = 3 20 | 21 | // EmailStatusDrafts 草稿箱 22 | EmailStatusDrafts int8 = 4 23 | 24 | // EmailStatusJunk 骚扰邮件 25 | EmailStatusJunk int8 = 5 26 | ) 27 | -------------------------------------------------------------------------------- /server/controllers/attachments.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Jinnrry/pmail/dto/response" 6 | "github.com/Jinnrry/pmail/services/attachments" 7 | "github.com/Jinnrry/pmail/utils/context" 8 | "github.com/spf13/cast" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | func GetAttachments(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 14 | urlInfos := strings.Split(req.RequestURI, "/") 15 | if len(urlInfos) != 4 { 16 | response.NewErrorResponse(response.ParamsError, "", "").FPrint(w) 17 | return 18 | } 19 | emailId := cast.ToInt(urlInfos[2]) 20 | cid := urlInfos[3] 21 | 22 | contentType, content := attachments.GetAttachments(ctx, emailId, cid) 23 | 24 | if len(content) == 0 { 25 | response.NewErrorResponse(response.ParamsError, "", "").FPrint(w) 26 | return 27 | } 28 | w.Header().Set("Content-Type", contentType) 29 | w.Write(content) 30 | } 31 | 32 | func Download(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 33 | urlInfos := strings.Split(req.RequestURI, "/") 34 | if len(urlInfos) != 5 { 35 | response.NewErrorResponse(response.ParamsError, "", "").FPrint(w) 36 | return 37 | } 38 | emailId := cast.ToInt(urlInfos[3]) 39 | index := cast.ToInt(urlInfos[4]) 40 | 41 | fileName, content := attachments.GetAttachmentsByIndex(ctx, emailId, index) 42 | 43 | if len(content) == 0 { 44 | response.NewErrorResponse(response.ParamsError, "", "").FPrint(w) 45 | return 46 | } 47 | w.Header().Set("ContentType", "application/octet-stream") 48 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment;filename=%s", fileName)) 49 | w.Write(content) 50 | } 51 | -------------------------------------------------------------------------------- /server/controllers/base.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/utils/context" 5 | "net/http" 6 | ) 7 | 8 | type HandlerFunc func(*context.Context, http.ResponseWriter, *http.Request) 9 | -------------------------------------------------------------------------------- /server/controllers/email/delete.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/Jinnrry/pmail/dto/response" 6 | "github.com/Jinnrry/pmail/services/del_email" 7 | "github.com/Jinnrry/pmail/utils/context" 8 | log "github.com/sirupsen/logrus" 9 | "io" 10 | "net/http" 11 | ) 12 | 13 | type emailDeleteRequest struct { 14 | IDs []int `json:"ids"` 15 | ForcedDel bool `json:"forcedDel"` 16 | } 17 | 18 | func EmailDelete(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 19 | reqBytes, err := io.ReadAll(req.Body) 20 | if err != nil { 21 | log.WithContext(ctx).Errorf("%+v", err) 22 | } 23 | var reqData emailDeleteRequest 24 | err = json.Unmarshal(reqBytes, &reqData) 25 | if err != nil { 26 | log.WithContext(ctx).Errorf("%+v", err) 27 | } 28 | 29 | if len(reqData.IDs) <= 0 { 30 | response.NewErrorResponse(response.ParamsError, "ID错误", "").FPrint(w) 31 | return 32 | } 33 | 34 | err = del_email.DelEmail(ctx, reqData.IDs, reqData.ForcedDel) 35 | if err != nil { 36 | response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) 37 | return 38 | } 39 | response.NewSuccessResponse("success").FPrint(w) 40 | 41 | } 42 | -------------------------------------------------------------------------------- /server/controllers/email/detail.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/Jinnrry/pmail/dto/response" 6 | "github.com/Jinnrry/pmail/services/detail" 7 | "github.com/Jinnrry/pmail/utils/context" 8 | log "github.com/sirupsen/logrus" 9 | "io" 10 | "net/http" 11 | ) 12 | 13 | type emailDetailRequest struct { 14 | ID int `json:"id"` 15 | } 16 | 17 | func EmailDetail(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 18 | reqBytes, err := io.ReadAll(req.Body) 19 | if err != nil { 20 | log.WithContext(ctx).Errorf("%+v", err) 21 | } 22 | var retData emailDetailRequest 23 | err = json.Unmarshal(reqBytes, &retData) 24 | if err != nil { 25 | log.WithContext(ctx).Errorf("%+v", err) 26 | } 27 | 28 | if retData.ID <= 0 { 29 | response.NewErrorResponse(response.ParamsError, "ID错误", "").FPrint(w) 30 | return 31 | } 32 | 33 | email, err := detail.GetEmailDetail(ctx, retData.ID, true) 34 | if err != nil { 35 | response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) 36 | return 37 | } 38 | 39 | response.NewSuccessResponse(email).FPrint(w) 40 | 41 | } 42 | -------------------------------------------------------------------------------- /server/controllers/email/list.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/Jinnrry/pmail/dto" 6 | "github.com/Jinnrry/pmail/dto/response" 7 | "github.com/Jinnrry/pmail/services/list" 8 | "github.com/Jinnrry/pmail/utils/context" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/spf13/cast" 11 | "io" 12 | "math" 13 | "net/http" 14 | ) 15 | 16 | type emailListResponse struct { 17 | CurrentPage int `json:"current_page"` 18 | TotalPage int `json:"total_page"` 19 | List []*emilItem `json:"list"` 20 | } 21 | 22 | type emilItem struct { 23 | ID int `json:"id"` 24 | Title string `json:"title"` 25 | Desc string `json:"desc"` 26 | Datetime string `json:"datetime"` 27 | IsRead bool `json:"is_read"` 28 | Sender User `json:"sender"` 29 | To []User `json:"to"` 30 | Dangerous bool `json:"dangerous"` 31 | Error string `json:"error"` 32 | } 33 | 34 | type User struct { 35 | Name string `json:"Name"` 36 | EmailAddress string `json:"EmailAddress"` 37 | } 38 | 39 | type emailRequest struct { 40 | Keyword string `json:"keyword"` 41 | Tag string `json:"tag"` 42 | CurrentPage int `json:"current_page"` 43 | PageSize int `json:"page_size"` 44 | } 45 | 46 | func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 47 | var lst []*emilItem 48 | reqBytes, err := io.ReadAll(req.Body) 49 | if err != nil { 50 | log.WithContext(ctx).Errorf("%+v", err) 51 | } 52 | var retData emailRequest 53 | err = json.Unmarshal(reqBytes, &retData) 54 | if err != nil { 55 | log.WithContext(ctx).Errorf("%+v", err) 56 | } 57 | 58 | offset := 0 59 | if retData.CurrentPage >= 1 { 60 | offset = (retData.CurrentPage - 1) * retData.PageSize 61 | } 62 | 63 | if retData.PageSize == 0 { 64 | retData.PageSize = 15 65 | } 66 | 67 | var tagInfo dto.SearchTag = dto.SearchTag{ 68 | Type: -1, 69 | Status: -1, 70 | GroupId: -1, 71 | } 72 | _ = json.Unmarshal([]byte(retData.Tag), &tagInfo) 73 | 74 | emailList, total := list.GetEmailList(ctx, tagInfo, retData.Keyword, false, offset, retData.PageSize) 75 | 76 | for _, email := range emailList { 77 | var sender User 78 | _ = json.Unmarshal([]byte(email.Sender), &sender) 79 | 80 | var tos []User 81 | _ = json.Unmarshal([]byte(email.To), &tos) 82 | 83 | lst = append(lst, &emilItem{ 84 | ID: email.Id, 85 | Title: email.Subject, 86 | Desc: email.Text.String, 87 | Datetime: email.SendDate.Format("2006-01-02 15:04:05"), 88 | IsRead: email.IsRead == 1, 89 | Sender: sender, 90 | To: tos, 91 | Dangerous: email.SPFCheck == 0 && email.DKIMCheck == 0, 92 | Error: email.Error.String, 93 | }) 94 | } 95 | 96 | ret := emailListResponse{ 97 | CurrentPage: retData.CurrentPage, 98 | TotalPage: cast.ToInt(math.Ceil(cast.ToFloat64(total) / cast.ToFloat64(retData.PageSize))), 99 | List: lst, 100 | } 101 | response.NewSuccessResponse(ret).FPrint(w) 102 | } 103 | -------------------------------------------------------------------------------- /server/controllers/email/move.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/Jinnrry/pmail/dto/response" 6 | "github.com/Jinnrry/pmail/models" 7 | "github.com/Jinnrry/pmail/services/group" 8 | "github.com/Jinnrry/pmail/utils/context" 9 | log "github.com/sirupsen/logrus" 10 | "io" 11 | "net/http" 12 | ) 13 | 14 | type moveRequest struct { 15 | GroupId int `json:"group_id"` 16 | GroupName string `json:"group_name"` 17 | IDs []int `json:"ids"` 18 | } 19 | 20 | func Move(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 21 | reqBytes, err := io.ReadAll(req.Body) 22 | if err != nil { 23 | log.WithContext(ctx).Errorf("%+v", err) 24 | } 25 | var reqData moveRequest 26 | err = json.Unmarshal(reqBytes, &reqData) 27 | if err != nil { 28 | log.WithContext(ctx).Errorf("%+v", err) 29 | } 30 | 31 | if len(reqData.IDs) <= 0 { 32 | response.NewErrorResponse(response.ParamsError, "ID错误", "").FPrint(w) 33 | return 34 | } 35 | 36 | if name, ok := models.GroupCodeToName[reqData.GroupId]; ok { 37 | err := group.Move2DefaultBox(ctx, reqData.IDs, name) 38 | if err != nil { 39 | response.NewErrorResponse(response.ServerError, "Error", err.Error()).FPrint(w) 40 | return 41 | } 42 | } else if !group.MoveMailToGroup(ctx, reqData.IDs, reqData.GroupId) { 43 | response.NewErrorResponse(response.ServerError, "Error", "").FPrint(w) 44 | return 45 | } 46 | response.NewSuccessResponse("success").FPrint(w) 47 | 48 | } 49 | -------------------------------------------------------------------------------- /server/controllers/email/read.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/Jinnrry/pmail/dto/response" 6 | "github.com/Jinnrry/pmail/services/detail" 7 | "github.com/Jinnrry/pmail/utils/context" 8 | log "github.com/sirupsen/logrus" 9 | "io" 10 | "net/http" 11 | ) 12 | 13 | type markReadRequest struct { 14 | IDs []int `json:"ids"` 15 | } 16 | 17 | func MarkRead(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 18 | reqBytes, err := io.ReadAll(req.Body) 19 | if err != nil { 20 | log.WithContext(ctx).Errorf("%+v", err) 21 | } 22 | var reqData markReadRequest 23 | err = json.Unmarshal(reqBytes, &reqData) 24 | if err != nil { 25 | log.WithContext(ctx).Errorf("%+v", err) 26 | } 27 | 28 | if len(reqData.IDs) <= 0 { 29 | response.NewErrorResponse(response.ParamsError, "ID错误", "").FPrint(w) 30 | return 31 | } 32 | 33 | for _, id := range reqData.IDs { 34 | detail.GetEmailDetail(ctx, id, true) 35 | } 36 | 37 | if err != nil { 38 | response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) 39 | return 40 | } 41 | response.NewSuccessResponse("success").FPrint(w) 42 | 43 | } 44 | -------------------------------------------------------------------------------- /server/controllers/group.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/Jinnrry/pmail/dto" 6 | "github.com/Jinnrry/pmail/dto/response" 7 | "github.com/Jinnrry/pmail/i18n" 8 | "github.com/Jinnrry/pmail/models" 9 | "github.com/Jinnrry/pmail/services/group" 10 | "github.com/Jinnrry/pmail/utils/array" 11 | "github.com/Jinnrry/pmail/utils/context" 12 | log "github.com/sirupsen/logrus" 13 | "io" 14 | "net/http" 15 | ) 16 | 17 | func GetUserGroupList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 18 | defaultGroup := []*models.Group{ 19 | {models.INBOX, i18n.GetText(ctx.Lang, "inbox"), 0, 0, "/"}, 20 | {models.Junk, i18n.GetText(ctx.Lang, "junk"), 0, 0, "/"}, 21 | {models.Deleted, i18n.GetText(ctx.Lang, "deleted"), 0, 0, "/"}, 22 | } 23 | 24 | infos := group.GetGroupList(ctx) 25 | 26 | response.NewSuccessResponse(append(defaultGroup, infos...)).FPrint(w) 27 | } 28 | 29 | func GetUserGroup(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 30 | 31 | retData := []*group.GroupItem{ 32 | { 33 | Label: i18n.GetText(ctx.Lang, "all_email"), 34 | Children: []*group.GroupItem{ 35 | { 36 | Label: i18n.GetText(ctx.Lang, "inbox"), 37 | Tag: dto.SearchTag{Type: 0, Status: -1, GroupId: 0}.ToString(), 38 | }, 39 | { 40 | Label: i18n.GetText(ctx.Lang, "outbox"), 41 | Tag: dto.SearchTag{Type: 1, Status: -1}.ToString(), 42 | }, 43 | { 44 | Label: i18n.GetText(ctx.Lang, "sketch"), 45 | Tag: dto.SearchTag{Type: 1, Status: 0}.ToString(), 46 | }, 47 | { 48 | Label: i18n.GetText(ctx.Lang, "junk"), 49 | Tag: dto.SearchTag{Type: -1, Status: 5}.ToString(), 50 | }, 51 | { 52 | Label: i18n.GetText(ctx.Lang, "deleted"), 53 | Tag: dto.SearchTag{Type: -1, Status: 3}.ToString(), 54 | }, 55 | }, 56 | }, 57 | } 58 | 59 | retData = array.Merge(retData, group.GetGroupInfoList(ctx)) 60 | 61 | response.NewSuccessResponse(retData).FPrint(w) 62 | } 63 | 64 | type addGroupRequest struct { 65 | Name string `json:"name"` 66 | ParentId int `json:"parent_id"` 67 | } 68 | 69 | func AddGroup(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 70 | var reqData *addGroupRequest 71 | reqBytes, err := io.ReadAll(req.Body) 72 | if err != nil { 73 | log.WithContext(ctx).Errorf("%+v", err) 74 | } 75 | err = json.Unmarshal(reqBytes, &reqData) 76 | if err != nil { 77 | log.WithContext(ctx).Errorf("%+v", err) 78 | } 79 | 80 | newGroup, err := group.CreateGroup(ctx, reqData.Name, reqData.ParentId) 81 | 82 | if err != nil { 83 | response.NewErrorResponse(response.ServerError, "DBError", err.Error()).FPrint(w) 84 | return 85 | } 86 | 87 | response.NewSuccessResponse(newGroup.ID).FPrint(w) 88 | } 89 | 90 | type delGroupRequest struct { 91 | Id int `json:"id"` 92 | } 93 | 94 | func DelGroup(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 95 | var reqData *delGroupRequest 96 | reqBytes, err := io.ReadAll(req.Body) 97 | if err != nil { 98 | log.WithContext(ctx).Errorf("%+v", err) 99 | } 100 | err = json.Unmarshal(reqBytes, &reqData) 101 | if err != nil { 102 | log.WithContext(ctx).Errorf("%+v", err) 103 | } 104 | succ, err := group.DelGroup(ctx, reqData.Id) 105 | 106 | if err != nil { 107 | response.NewErrorResponse(response.ServerError, "DBError", err.Error()).FPrint(w) 108 | return 109 | } 110 | response.NewSuccessResponse(succ).FPrint(w) 111 | } 112 | -------------------------------------------------------------------------------- /server/controllers/interceptor.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/config" 5 | "net/http" 6 | ) 7 | 8 | func Interceptor(w http.ResponseWriter, r *http.Request) { 9 | URL := "https://" + config.Instance.WebDomain + r.URL.Path 10 | http.Redirect(w, r, URL, http.StatusMovedPermanently) 11 | } 12 | -------------------------------------------------------------------------------- /server/controllers/login.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "github.com/Jinnrry/pmail/config" 7 | "github.com/Jinnrry/pmail/db" 8 | "github.com/Jinnrry/pmail/dto/response" 9 | "github.com/Jinnrry/pmail/i18n" 10 | "github.com/Jinnrry/pmail/models" 11 | "github.com/Jinnrry/pmail/session" 12 | "github.com/Jinnrry/pmail/utils/array" 13 | "github.com/Jinnrry/pmail/utils/context" 14 | "github.com/Jinnrry/pmail/utils/errors" 15 | "github.com/Jinnrry/pmail/utils/password" 16 | log "github.com/sirupsen/logrus" 17 | "io" 18 | "net/http" 19 | ) 20 | 21 | type loginRequest struct { 22 | Account string `json:"account"` 23 | Password string `json:"password"` 24 | } 25 | 26 | func Login(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 27 | 28 | reqBytes, err := io.ReadAll(req.Body) 29 | if err != nil { 30 | log.Errorf("%+v", err) 31 | } 32 | var reqData loginRequest 33 | err = json.Unmarshal(reqBytes, &reqData) 34 | if err != nil { 35 | log.Errorf("%+v", err) 36 | } 37 | 38 | var user models.User 39 | 40 | encodePwd := password.Encode(reqData.Password) 41 | _, err = db.Instance.Where("account =? and password =? and disabled=0", reqData.Account, encodePwd).Get(&user) 42 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 43 | log.Errorf("%+v", err) 44 | } 45 | 46 | if user.ID != 0 { 47 | userStr, _ := json.Marshal(user) 48 | session.Instance.Put(req.Context(), "user", string(userStr)) 49 | 50 | domains := config.Instance.Domains 51 | domains = array.Difference(domains, []string{config.Instance.Domain}) 52 | domains = append([]string{config.Instance.Domain}, domains...) 53 | 54 | response.NewSuccessResponse(map[string]any{ 55 | "account": user.Account, 56 | "name": user.Name, 57 | "is_admin": user.IsAdmin, 58 | "domains": domains, 59 | }).FPrint(w) 60 | } else { 61 | response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "aperror"), "").FPrint(w) 62 | } 63 | } 64 | 65 | func Logout(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 66 | session.Instance.Clear(ctx.Context) 67 | response.NewSuccessResponse("Success").FPrint(w) 68 | } 69 | -------------------------------------------------------------------------------- /server/controllers/ping.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/dto/response" 5 | "net/http" 6 | ) 7 | 8 | func Ping(w http.ResponseWriter, req *http.Request) { 9 | response.NewSuccessResponse("pong").FPrint(w) 10 | } 11 | -------------------------------------------------------------------------------- /server/controllers/plugin.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/dto/response" 5 | "github.com/Jinnrry/pmail/hooks" 6 | "github.com/Jinnrry/pmail/utils/context" 7 | "io" 8 | "net/http" 9 | "strings" 10 | ) 11 | 12 | func GetPluginList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 13 | ret := []string{} 14 | for s, _ := range hooks.HookList { 15 | ret = append(ret, s) 16 | } 17 | response.NewSuccessResponse(ret).FPrint(w) 18 | 19 | } 20 | 21 | func SettingsHtml(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 22 | args := strings.Split(req.RequestURI, "/") 23 | if len(args) < 4 { 24 | response.NewErrorResponse(response.ParamsError, "404", "").FPrint(w) 25 | return 26 | } 27 | 28 | pluginName := args[4] 29 | if plugin, ok := hooks.HookList[pluginName]; ok { 30 | dt, err := io.ReadAll(req.Body) 31 | if err != nil { 32 | response.NewErrorResponse(response.ParamsError, err.Error(), "").FPrint(w) 33 | return 34 | } 35 | html := plugin.SettingsHtml(ctx, 36 | strings.Join(args[4:], "/"), 37 | string(dt), 38 | ) 39 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 40 | 41 | w.Write([]byte(html)) 42 | return 43 | 44 | } 45 | response.NewErrorResponse(response.ParamsError, "404", "") 46 | } 47 | -------------------------------------------------------------------------------- /server/controllers/rule.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/Jinnrry/pmail/db" 6 | "github.com/Jinnrry/pmail/dto" 7 | "github.com/Jinnrry/pmail/dto/response" 8 | "github.com/Jinnrry/pmail/i18n" 9 | "github.com/Jinnrry/pmail/models" 10 | "github.com/Jinnrry/pmail/services/rule" 11 | "github.com/Jinnrry/pmail/utils/address" 12 | "github.com/Jinnrry/pmail/utils/array" 13 | "github.com/Jinnrry/pmail/utils/context" 14 | "github.com/Jinnrry/pmail/utils/errors" 15 | log "github.com/sirupsen/logrus" 16 | "io" 17 | "net/http" 18 | ) 19 | 20 | func GetRule(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 21 | res := rule.GetAllRules(ctx, ctx.UserID) 22 | response.NewSuccessResponse(res).FPrint(w) 23 | } 24 | 25 | func UpsertRule(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 26 | 27 | requestBody, err := io.ReadAll(req.Body) 28 | if err != nil { 29 | log.WithContext(ctx).Errorf("ReadError:%v", err) 30 | return 31 | } 32 | 33 | var data *dto.Rule 34 | err = json.Unmarshal(requestBody, &data) 35 | if err != nil { 36 | response.NewErrorResponse(response.ParamsError, "params error", err).FPrint(w) 37 | return 38 | } 39 | 40 | if data.Action == dto.FORWARD && !address.IsValidEmailAddress(data.Params) { 41 | 42 | response.NewErrorResponse(response.ParamsError, "ParamsError error", i18n.GetText(ctx.Lang, "invalid_email_address")).FPrint(w) 43 | return 44 | } 45 | 46 | for _, r := range data.Rules { 47 | if !array.InArray(r.Field, []string{"From", "Subject", "To", "Cc", "Text", "Html", "Content"}) { 48 | response.NewErrorResponse(response.ParamsError, "ParamsError error", "params error! Rule Field Error!").FPrint(w) 49 | return 50 | } 51 | } 52 | 53 | err = save(ctx, data.Encode()) 54 | if err != nil { 55 | response.NewErrorResponse(response.ServerError, "server error", err).FPrint(w) 56 | return 57 | } 58 | response.NewSuccessResponse("succ").FPrint(w) 59 | } 60 | 61 | func save(ctx *context.Context, p *models.Rule) error { 62 | 63 | if p.Id > 0 { 64 | _, err := db.Instance.Exec(db.WithContext(ctx, "update rule set name=? ,value = ? ,action = ?,params = ?,sort = ? where id = ?"), p.Name, p.Value, p.Action, p.Params, p.Sort, p.Id) 65 | if err != nil { 66 | return errors.Wrap(err) 67 | } 68 | return nil 69 | } else { 70 | _, err := db.Instance.Exec(db.WithContext(ctx, "insert into rule (name,value,user_id,action,params,sort) values (?,?,?,?,?,?)"), p.Name, p.Value, ctx.UserID, p.Action, p.Params, p.Sort) 71 | if err != nil { 72 | return errors.Wrap(err) 73 | } 74 | return nil 75 | } 76 | 77 | } 78 | 79 | type delRuleReq struct { 80 | Id int `json:"id"` 81 | } 82 | 83 | func DelRule(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 84 | requestBody, err := io.ReadAll(req.Body) 85 | if err != nil { 86 | log.WithContext(ctx).Errorf("ReadError:%v", err) 87 | return 88 | } 89 | 90 | var data delRuleReq 91 | err = json.Unmarshal(requestBody, &data) 92 | if err != nil { 93 | response.NewErrorResponse(response.ParamsError, "params error", err).FPrint(w) 94 | return 95 | } 96 | 97 | if data.Id <= 0 { 98 | response.NewErrorResponse(response.ParamsError, "params error", "id is empty").FPrint(w) 99 | return 100 | } 101 | 102 | _, err = db.Instance.Exec(db.WithContext(ctx, "delete from rule where id =? and user_id =?"), data.Id, ctx.UserID) 103 | if err != nil { 104 | response.NewErrorResponse(response.ServerError, "unknown error", err).FPrint(w) 105 | return 106 | } 107 | 108 | response.NewSuccessResponse("succ").FPrint(w) 109 | } 110 | -------------------------------------------------------------------------------- /server/controllers/settings.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/Jinnrry/pmail/db" 6 | "github.com/Jinnrry/pmail/dto/response" 7 | "github.com/Jinnrry/pmail/i18n" 8 | "github.com/Jinnrry/pmail/utils/context" 9 | "github.com/Jinnrry/pmail/utils/password" 10 | log "github.com/sirupsen/logrus" 11 | "io" 12 | "net/http" 13 | ) 14 | 15 | type modifyPasswordRequest struct { 16 | Password string `json:"password"` 17 | } 18 | 19 | func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Request) { 20 | reqBytes, err := io.ReadAll(req.Body) 21 | if err != nil { 22 | log.Errorf("%+v", err) 23 | } 24 | var retData modifyPasswordRequest 25 | err = json.Unmarshal(reqBytes, &retData) 26 | if err != nil { 27 | log.Errorf("%+v", err) 28 | } 29 | 30 | if retData.Password != "" { 31 | encodePwd := password.Encode(retData.Password) 32 | 33 | _, err := db.Instance.Exec(db.WithContext(ctx, "update user set password = ? where id =?"), encodePwd, ctx.UserID) 34 | if err != nil { 35 | response.NewErrorResponse(response.ServerError, i18n.GetText(ctx.Lang, "unknowError"), "").FPrint(w) 36 | return 37 | } 38 | 39 | } 40 | 41 | response.NewSuccessResponse(i18n.GetText(ctx.Lang, "succ")).FPrint(w) 42 | } 43 | -------------------------------------------------------------------------------- /server/dto/parsemail/dkim.go: -------------------------------------------------------------------------------- 1 | package parsemail 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "fmt" 9 | "github.com/Jinnrry/pmail/config" 10 | "github.com/Jinnrry/pmail/utils/consts" 11 | "github.com/Jinnrry/pmail/utils/context" 12 | "github.com/emersion/go-msgauth/dkim" 13 | log "github.com/sirupsen/logrus" 14 | "golang.org/x/crypto/ed25519" 15 | "io" 16 | "os" 17 | "strings" 18 | ) 19 | 20 | type Dkim struct { 21 | privateKey crypto.Signer 22 | } 23 | 24 | var instance *Dkim 25 | 26 | func Init() { 27 | privateKey, err := loadPrivateKey(config.Instance.DkimPrivateKeyPath) 28 | if err != nil { 29 | panic(config.Instance.DkimPrivateKeyPath + 30 | " DKIM load fail! Please set dkim! dkim私钥加载失败!请先设置dkim秘钥" + 31 | err.Error()) 32 | } 33 | 34 | instance = &Dkim{ 35 | privateKey: privateKey, 36 | } 37 | } 38 | 39 | func loadPrivateKey(path string) (crypto.Signer, error) { 40 | b, err := os.ReadFile(path) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | block, _ := pem.Decode(b) 46 | if block == nil { 47 | return nil, fmt.Errorf("no PEM data found") 48 | } 49 | 50 | switch strings.ToUpper(block.Type) { 51 | case "PRIVATE KEY": 52 | k, err := x509.ParsePKCS8PrivateKey(block.Bytes) 53 | if err != nil { 54 | return nil, err 55 | } 56 | return k.(crypto.Signer), nil 57 | case "RSA PRIVATE KEY": 58 | return x509.ParsePKCS1PrivateKey(block.Bytes) 59 | case "EDDSA PRIVATE KEY": 60 | if len(block.Bytes) != ed25519.PrivateKeySize { 61 | return nil, fmt.Errorf("invalid Ed25519 private key size") 62 | } 63 | return ed25519.PrivateKey(block.Bytes), nil 64 | default: 65 | return nil, fmt.Errorf("unknown private key type: '%v'", block.Type) 66 | } 67 | } 68 | 69 | func (p *Dkim) Sign(msgData string) []byte { 70 | var b bytes.Buffer 71 | r := strings.NewReader(msgData) 72 | 73 | options := &dkim.SignOptions{ 74 | Domain: config.Instance.Domain, 75 | Selector: "default", 76 | Signer: p.privateKey, 77 | } 78 | 79 | if err := dkim.Sign(&b, r, options); err != nil { 80 | log.Errorf("%+v", err) 81 | return []byte(msgData) 82 | } 83 | return b.Bytes() 84 | } 85 | 86 | func Check(ctx *context.Context, mail io.Reader) bool { 87 | 88 | verifications, err := dkim.Verify(mail) 89 | if err != nil { 90 | log.WithContext(ctx).Warnf("DKIM Error:%v", err) 91 | } 92 | 93 | if len(verifications) == 0 { 94 | return false 95 | } 96 | 97 | for _, v := range verifications { 98 | if v.Domain == consts.TEST_DOMAIN { 99 | return true 100 | } 101 | if v.Err == nil { 102 | log.Println("Valid signature for:", v.Domain) 103 | } else { 104 | log.Println("Invalid signature for:", v.Domain, v.Err) 105 | return false 106 | } 107 | } 108 | return true 109 | } 110 | -------------------------------------------------------------------------------- /server/dto/parsemail/dkim_test.go: -------------------------------------------------------------------------------- 1 | package parsemail 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestCheck(t *testing.T) { 9 | 10 | res := Check(nil, strings.NewReader(`Received: from jdl.ac.cn ([159.226.42.8]) 11 | 12 | xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 13 | `)) 14 | if res != false { 15 | t.Errorf("DKIM Error") 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /server/dto/parsemail/email_test.go: -------------------------------------------------------------------------------- 1 | package parsemail 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/emersion/go-message" 7 | "io" 8 | "testing" 9 | ) 10 | 11 | func Test_buildUser(t *testing.T) { 12 | u := buildUser("Jinnrry N ") 13 | if u.EmailAddress != "jiangwei1995910@gmail.com" { 14 | t.Error("error") 15 | } 16 | if u.Name != "Jinnrry N" { 17 | t.Error("error") 18 | } 19 | 20 | u = buildUser("=?UTF-8?B?YWRtaW5AamlubnJyeS5jb20=?=") 21 | if u.EmailAddress != "admin@jinnrry.com" { 22 | t.Error("error") 23 | } 24 | if u.Name != "admin@jinnrry.com" { 25 | t.Error("error") 26 | } 27 | 28 | u = buildUser("\"admin@jinnrry.com\" ") 29 | if u.EmailAddress != "admin@jinnrry.com" { 30 | t.Error("error") 31 | } 32 | if u.Name != "admin@jinnrry.com" { 33 | t.Error("error") 34 | } 35 | } 36 | 37 | func TestEmailBuidlers(t *testing.T) { 38 | var b bytes.Buffer 39 | 40 | var h message.Header 41 | h.SetContentType("multipart/alternative", nil) 42 | w, err := message.CreateWriter(&b, h) 43 | if err != nil { 44 | } 45 | 46 | var h1 message.Header 47 | h1.SetContentType("text/html", nil) 48 | w1, err := w.CreatePart(h1) 49 | if err != nil { 50 | } 51 | io.WriteString(w1, "

Hello World!

This is an HTML part.

") 52 | w1.Close() 53 | 54 | var h2 message.Header 55 | h2.SetContentType("text/plain", nil) 56 | w2, err := w.CreatePart(h2) 57 | if err != nil { 58 | } 59 | io.WriteString(w2, "Hello World!\n\nThis is a text part.") 60 | w2.Close() 61 | 62 | w.Close() 63 | 64 | fmt.Println(b.String()) 65 | } 66 | 67 | func TestEmail_builder(t *testing.T) { 68 | e := Email{ 69 | From: buildUser("i@test.com"), 70 | To: buildUsers([]string{"to@test.com"}), 71 | Subject: "Title中文", 72 | HTML: []byte("Html"), 73 | Text: []byte("Text"), 74 | Attachments: []*Attachment{ 75 | { 76 | Filename: "a.png", 77 | ContentType: "image/jpeg", 78 | Content: []byte("aaa"), 79 | ContentID: "1", 80 | }, 81 | }, 82 | } 83 | 84 | rest := e.BuildBytes(nil, false) 85 | fmt.Println(string(rest)) 86 | } 87 | -------------------------------------------------------------------------------- /server/dto/response/email.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "github.com/Jinnrry/pmail/models" 4 | 5 | type EmailResponseData struct { 6 | models.Email `xorm:"extends"` 7 | IsRead int8 `json:"is_read"` 8 | SerialNumber int `json:"serial_number"` 9 | UeId int `json:"ue_id"` 10 | } 11 | 12 | type UserEmailUIDData struct { 13 | models.UserEmail `xorm:"extends"` 14 | SerialNumber int `json:"serial_number"` 15 | } 16 | -------------------------------------------------------------------------------- /server/dto/response/response.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | const ( 9 | NeedSetup = 402 10 | NeedLogin = 403 11 | NoAccessPrivileges = 405 12 | ParamsError = 100 13 | ServerError = 500 14 | ) 15 | 16 | type Response struct { 17 | ErrorNo int `json:"errorNo"` 18 | ErrorMsg string `json:"errorMsg"` 19 | Data any `json:"data"` 20 | } 21 | 22 | func (p *Response) FPrint(w http.ResponseWriter) { 23 | bytesData, _ := json.Marshal(p) 24 | w.Write(bytesData) 25 | } 26 | 27 | func NewSuccessResponse(data any) *Response { 28 | return &Response{ 29 | Data: data, 30 | } 31 | } 32 | 33 | func NewErrorResponse(errorNo int, errorMsg string, data any) *Response { 34 | return &Response{ 35 | ErrorNo: errorNo, 36 | ErrorMsg: errorMsg, 37 | Data: data, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/dto/rule.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/Jinnrry/pmail/models" 6 | ) 7 | 8 | type RuleType int 9 | 10 | // 1已读,2转发,3删除 11 | var ( 12 | READ RuleType = 1 13 | FORWARD RuleType = 2 14 | DELETE RuleType = 3 15 | MOVE RuleType = 4 16 | ) 17 | 18 | type Rule struct { 19 | Id int `json:"id"` 20 | UserId int `json:"user_id"` 21 | Name string `json:"name"` 22 | Rules []*Value `json:"rules"` 23 | Action RuleType `json:"action"` 24 | Params string `json:"params"` 25 | Sort int `json:"sort"` 26 | } 27 | 28 | type Value struct { 29 | Field string `json:"field"` 30 | Type string `json:"type"` 31 | Rule string `json:"rule"` 32 | } 33 | 34 | func (p *Rule) Decode(data *models.Rule) *Rule { 35 | json.Unmarshal([]byte(data.Value), &p.Rules) 36 | p.Id = data.Id 37 | p.Name = data.Name 38 | p.Action = RuleType(data.Action) 39 | p.Sort = data.Sort 40 | p.Params = data.Params 41 | p.UserId = data.UserId 42 | return p 43 | } 44 | 45 | func (p *Rule) Encode() *models.Rule { 46 | v, _ := json.Marshal(p.Rules) 47 | ret := &models.Rule{ 48 | Id: p.Id, 49 | Name: p.Name, 50 | Value: string(v), 51 | Action: int(p.Action), 52 | Sort: p.Sort, 53 | Params: p.Params, 54 | } 55 | return ret 56 | } 57 | -------------------------------------------------------------------------------- /server/dto/tag.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import "encoding/json" 4 | 5 | type SearchTag struct { 6 | Type int8 `json:"type"` // -1 不限 7 | Status int8 `json:"status"` // -1 不限 8 | GroupId int `json:"group_id"` // -1 不限 9 | } 10 | 11 | func (t SearchTag) ToString() string { 12 | data, _ := json.Marshal(t) 13 | return string(data) 14 | } 15 | -------------------------------------------------------------------------------- /server/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Jinnrry/pmail 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/Jinnrry/gopop v0.0.0-20231113115125-fbdf52ae39ea 7 | github.com/alexedwards/scs/mysqlstore v0.0.0-20250417082927-ab20b3feb5e9 8 | github.com/alexedwards/scs/postgresstore v0.0.0-20250417082927-ab20b3feb5e9 9 | github.com/alexedwards/scs/sqlite3store v0.0.0-20250417082927-ab20b3feb5e9 10 | github.com/alexedwards/scs/v2 v2.8.0 11 | github.com/dlclark/regexp2 v1.11.5 12 | github.com/emersion/go-imap/v2 v2.0.0-beta.5 13 | github.com/emersion/go-message v0.18.2 14 | github.com/emersion/go-msgauth v0.6.8 15 | github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 16 | github.com/emersion/go-smtp v0.21.3 17 | github.com/go-acme/lego/v4 v4.23.1 18 | github.com/go-sql-driver/mysql v1.9.2 19 | github.com/lib/pq v1.10.9 20 | github.com/mileusna/spf v0.9.5 21 | github.com/sirupsen/logrus v1.9.3 22 | github.com/spf13/cast v1.7.1 23 | golang.org/x/crypto v0.37.0 24 | golang.org/x/text v0.24.0 25 | modernc.org/sqlite v1.37.0 26 | xorm.io/builder v0.3.13 27 | xorm.io/xorm v1.3.9 28 | ) 29 | 30 | require ( 31 | filippo.io/edwards25519 v1.1.0 // indirect 32 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 33 | github.com/dustin/go-humanize v1.0.1 // indirect 34 | github.com/go-jose/go-jose/v4 v4.1.0 // indirect 35 | github.com/goccy/go-json v0.10.5 // indirect 36 | github.com/golang/snappy v1.0.0 // indirect 37 | github.com/google/uuid v1.6.0 // indirect 38 | github.com/json-iterator/go v1.1.12 // indirect 39 | github.com/mattn/go-isatty v0.0.20 // indirect 40 | github.com/miekg/dns v1.1.65 // indirect 41 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 42 | github.com/modern-go/reflect2 v1.0.2 // indirect 43 | github.com/ncruces/go-strftime v0.1.9 // indirect 44 | github.com/nxadm/tail v1.4.11 // indirect 45 | github.com/onsi/ginkgo v1.16.4 // indirect 46 | github.com/onsi/gomega v1.31.1 // indirect 47 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 48 | github.com/rogpeppe/go-internal v1.12.0 // indirect 49 | github.com/syndtr/goleveldb v1.0.0 // indirect 50 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 51 | golang.org/x/mod v0.24.0 // indirect 52 | golang.org/x/net v0.39.0 // indirect 53 | golang.org/x/sync v0.13.0 // indirect 54 | golang.org/x/sys v0.32.0 // indirect 55 | golang.org/x/tools v0.32.0 // indirect 56 | modernc.org/libc v1.63.0 // indirect 57 | modernc.org/mathutil v1.7.1 // indirect 58 | modernc.org/memory v1.10.0 // indirect 59 | ) 60 | -------------------------------------------------------------------------------- /server/hooks/debug/debug.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Jinnrry/pmail/dto/parsemail" 6 | "github.com/Jinnrry/pmail/hooks/framework" 7 | "github.com/Jinnrry/pmail/models" 8 | "github.com/Jinnrry/pmail/utils/context" 9 | ) 10 | 11 | type Debug struct { 12 | } 13 | 14 | func NewDebug() *Debug { 15 | return &Debug{} 16 | } 17 | 18 | func (d Debug) SendBefore(ctx *context.Context, email *parsemail.Email) { 19 | fmt.Printf("[debug SendBefore] %+v ", email) 20 | 21 | } 22 | 23 | func (d Debug) SendAfter(ctx *context.Context, email *parsemail.Email, err map[string]error) { 24 | fmt.Printf("[debug SendAfter] %+v ", email) 25 | 26 | } 27 | 28 | func (d Debug) ReceiveParseBefore(ctx *context.Context, email *[]byte) { 29 | fmt.Printf("[debug ReceiveParseBefore] %s ", *email) 30 | 31 | } 32 | 33 | func (d Debug) ReceiveParseAfter(ctx *context.Context, email *parsemail.Email) { 34 | fmt.Printf("[debug ReceiveParseAfter] %+v ", email) 35 | email.Status = 5 36 | } 37 | 38 | func (d Debug) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) { 39 | fmt.Printf("[debug ReceiveSaveAfter] %+v %+v ", email, ue) 40 | } 41 | 42 | func (d Debug) GetName(ctx *context.Context) string { 43 | return "debug" 44 | } 45 | 46 | func (d Debug) SettingsHtml(ctx *context.Context, url string, requestData string) string { 47 | return "" 48 | } 49 | 50 | func main() { 51 | framework.CreatePlugin("debug_plugin", NewDebug()).Run() 52 | } 53 | -------------------------------------------------------------------------------- /server/hooks/spam_block/README.md: -------------------------------------------------------------------------------- 1 | # 插件介绍 2 | 3 | 使用机器学习的方式识别垃圾邮件。模型使用的是RETVec。模型参数约 200k,在我1核1G的服务器上,单次推理耗时约3秒,Mac M1上可达到毫秒级耗时。耗时上,其实可以将模型进行裁剪,转换为Tensorflow Lite模型,转换后模型的资源消耗应该更小。但是Lite模型部署比较繁琐,涉及大量C库的编译安装,过程过于复杂。另外 4 | 我觉得,这个模型在我这垃圾服务器上面都能勉强使用,其他所有人的服务器上面应该都能顺利运行了,没必要继续裁剪模型了。 5 | 6 | # Help 7 | 8 | 目前Google GMail使用的垃圾邮件识别算法也是RETVec,理论上识别效果能够达到GMail同等级别。但是,我并没有Google那样大量的训练集。欢迎大家以Pull 9 | Request的形式提交机器学习的各类样本数据。 10 | 11 | 你可以给testData和trainData这两个文件夹下面的csv文件提交PR,CSV文件每行的第一个数字表示数据类型,0表示正常邮件,1表示广告邮件,2表示诈骗邮件。 12 | 13 | 你可以使用export.go这个脚本或者从Release中下载[email_export工具](https://github.com/Jinnrry/PMail/releases/tag/v2.6.1)导出你全部的邮件数据,过滤掉隐私内容并且标记好分类后提交上来。 14 | 15 | # 如何运行 16 | 17 | 1、下载[emotion_model.zip](https://github.com/Jinnrry/PMail/releases/tag/v2.8.3)或者自己训练模型 18 | 19 | 2、使用docker运行tensorflow模型 20 | `docker run -d -p 127.0.0.1:8501:8501 \ 21 | -v "{模型文件位置}:/models/emotion_model" \ 22 | -e MODEL_NAME=emotion_model tensorflow/serving &` 23 | 24 | 3、CURL测试模型部署是否成功 25 | 26 | > 详细部署说明请参考[tensorflow官方](https://www.tensorflow.org/tfx/guide/serving?hl=zh-cn) 27 | 28 | ```bash 29 | curl -X POST http://localhost:8501/v1/models/emotion_model:predict -d '{ 30 | "instances": [ 31 | {"token":["各位同事请注意 这里是110,请大家立刻把银行卡账号密码回复发给我!"]} 32 | ] 33 | }' 34 | ``` 35 | 36 | 将得到类似输出: 37 | 38 | ```jsonc 39 | { 40 | "predictions": [ 41 | [ 42 | 0.394376636, 43 | // 正常邮件的得分 44 | 0.0055413493, 45 | // 广告邮件的得分 46 | 0.633584619 47 | // 诈骗邮件的得分,这里诈骗邮件得分最高,因此最可能为诈骗邮件 48 | ] 49 | ] 50 | } 51 | ``` 52 | 53 | 4、将spam_block插件移动到pmail插件目录 54 | 55 | 5、设置插件 56 | 57 | PMail后台->右上角设置按钮->插件设置->SpamBlock 58 | 59 | 接口地址表示模型api访问地址,如果你是使用Docker部署,PMail和tensorflow/serving容器需要设置为相同网络才能通信,并且需要把localhost替换为tensorflow/serving的容器名称 60 | 61 | # 模型效果 62 | 63 | trec06c数据集: 64 | 65 | loss: 0.0187 - acc: 0.9948 - val_loss: 0.0047 - val_acc: 0.9993 66 | 67 | 实际使用效果: 68 | 69 | 我最近一周的使用效果来看,实际使用效果远低于模型理论效果。猜测原因如下: 70 | 71 | trec06c数据集已经公开十多年了,目前应该市面上所有反垃圾系统都使用这个数据集训练过。这个训练集训练出来的特征可能具有普遍性,而对于发垃圾邮件的人来说,这十多年他们也大致摸透了哪些特征会被识别为垃圾邮件,因此他们会针对性的避开很多关键字以免被封 72 | 73 | 解决方案只能是加入更多更优质的训练数据,但是trec06c之后就没这样优质的训练数据了,因此如果大家愿意,欢迎贡献模型训练数据。另外,针对模型本身,也欢迎提出优化方案。 74 | 75 | # 训练模型 76 | 77 | `python train.py` 78 | 79 | # 测试模型 80 | 81 | `python test.py` 82 | 83 | # trec06c 数据集 84 | 85 | [trec06c_format.py](trec06c_format.py) 86 | 脚本用于整理trec06c数据集,将其转化为训练所需的数据格式。由于数据集版权限制,如有需要请前往[这里](https://plg.uwaterloo.ca/~gvcormac/treccorpus06/about.html) 87 | 自行下载,本项目中不直接引入数据集内容。 88 | 89 | # 致谢 90 | 91 | Tanks For [google-research/retvec](https://github.com/google-research/retvec) 92 | 93 | -------------------------------------------------------------------------------- /server/hooks/spam_block/export/Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o export_linux_amd64 export.go 3 | CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o export_windows_amd64.exe export.go 4 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o export_mac_amd64 export.go 5 | CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o export_mac_arm64 export.go 6 | -------------------------------------------------------------------------------- /server/hooks/spam_block/export/export.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Jinnrry/pmail/config" 6 | "github.com/Jinnrry/pmail/db" 7 | "github.com/Jinnrry/pmail/hooks/spam_block/tools" 8 | "github.com/Jinnrry/pmail/models" 9 | "github.com/spf13/cast" 10 | "os" 11 | ) 12 | 13 | func getType(emailId int) int { 14 | var ue models.UserEmail 15 | _, err := db.Instance.Table(&ue).Where("email_id = ?", emailId).Limit(1).Get(&ue) 16 | if err != nil { 17 | fmt.Println(err) 18 | } 19 | if ue.Status == 3 { 20 | return 2 21 | } 22 | 23 | if ue.Status == 5 { 24 | return 1 25 | } 26 | 27 | return 0 28 | } 29 | 30 | func main() { 31 | args := os.Args 32 | 33 | var id int 34 | if len(args) >= 2 { 35 | id = cast.ToInt(args[1]) 36 | } 37 | 38 | config.Init() 39 | err := db.Init("test") 40 | if err != nil { 41 | panic(err) 42 | } 43 | fmt.Println(config.Instance.DbDSN) 44 | 45 | fmt.Println("文件第一列是分类,0表示正常邮件,1表示垃圾邮件,2表示诈骗邮件") 46 | 47 | var start int 48 | file, err := os.OpenFile("data.csv", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0777) 49 | if err != nil { 50 | fmt.Println(err) 51 | } 52 | defer file.Close() 53 | for { 54 | var emails []models.Email 55 | if id > 0 { 56 | db.Instance.Table(&models.Email{}).Where("id = ?", id).OrderBy("id").Find(&emails) 57 | } else { 58 | db.Instance.Table(&models.Email{}).Where("id > ?", start).OrderBy("id").Find(&emails) 59 | } 60 | if len(emails) == 0 { 61 | break 62 | } 63 | for _, email := range emails { 64 | start = email.Id 65 | content := tools.Trim(tools.TrimHtml(email.Html.String)) 66 | if content == "" { 67 | content = tools.Trim(email.Text.String) 68 | } 69 | _, err = file.WriteString(fmt.Sprintf("%d \t%s %s\n", getType(email.Id), email.Subject, content)) 70 | if err != nil { 71 | fmt.Println(err) 72 | } 73 | //fmt.Printf("0 \t%s %s\n", email.Subject, trim(trimHtml(email.Html.String))) 74 | } 75 | if id > 0 { 76 | break 77 | } 78 | 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /server/hooks/spam_block/requirements.txt: -------------------------------------------------------------------------------- 1 | datasets==2.20.0 2 | numpy==1.20.3 3 | retvec==1.0.1 4 | tensorflow==2.12.1 5 | beautifulsoup4 6 | lxml -------------------------------------------------------------------------------- /server/hooks/spam_block/static/index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 21 |
22 | 23 | 24 |
25 |
26 | 27 |
28 | 29 | 31 |
32 | 33 |
34 | 35 | 36 |
37 | 38 |
39 | 40 | 42 |
43 | 44 | 45 | 46 |
47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /server/hooks/spam_block/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import numpy as np 3 | os.environ['TF_CPP_MIN_LOG_LEVEL'] = '1' # silence TF INFO messages 4 | import tensorflow as tf 5 | 6 | save_path = './emotion_model/1' 7 | 8 | model = tf.keras.models.load_model(save_path, compile=False) 9 | 10 | model.summary() 11 | 12 | CLASSES = { 13 | 0:'普通邮件', 14 | 1:'广告邮件', 15 | 2:'诈骗邮件' 16 | } 17 | 18 | def predict_emotions(txt): 19 | # recall it is multi-class so we need to get all prediction above a threshold (0.5) 20 | input = tf.constant( np.array([txt]) , dtype=tf.string ) 21 | 22 | preds = model(input)[0] 23 | maxClass = -1 24 | maxScore = 0 25 | for idx in range(3): 26 | if preds[idx] > maxScore: 27 | maxScore = preds[idx] 28 | maxClass = idx 29 | return maxClass 30 | 31 | 32 | maxClass = predict_emotions("各位同事请注意 这里是110,请大家立刻把银行卡账号密码回复发给我!") 33 | 34 | print("这个邮件属于:",CLASSES[maxClass]) -------------------------------------------------------------------------------- /server/hooks/spam_block/tools/tools.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | func Trim(src string) string { 9 | return strings.ReplaceAll(strings.ReplaceAll(src, "\r", ""), "\n", "") 10 | } 11 | 12 | func TrimHtml(src string) string { 13 | //将HTML标签全转换成小写 14 | re, _ := regexp.Compile("\\<[\\S\\s]+?\\>") 15 | src = re.ReplaceAllStringFunc(src, strings.ToLower) 16 | //去除STYLE 17 | re, _ = regexp.Compile("\\") 18 | src = re.ReplaceAllString(src, "") 19 | //去除SCRIPT 20 | re, _ = regexp.Compile("\\") 21 | src = re.ReplaceAllString(src, "") 22 | //去除所有尖括号内的HTML代码,并换成换行符 23 | re, _ = regexp.Compile("\\<[\\S\\s]+?\\>") 24 | src = re.ReplaceAllString(src, "\n") 25 | //去除连续的换行符 26 | re, _ = regexp.Compile("\\s{2,}") 27 | src = re.ReplaceAllString(src, "\n") 28 | return strings.TrimSpace(src) 29 | } 30 | -------------------------------------------------------------------------------- /server/hooks/spam_block/train.py: -------------------------------------------------------------------------------- 1 | import retvec 2 | import datasets 3 | import os 4 | 5 | os.environ['TF_CPP_MIN_LOG_LEVEL'] = '1' # silence TF INFO messages 6 | import tensorflow as tf 7 | import numpy as np 8 | from tensorflow.keras import layers 9 | from retvec.tf import RETVecTokenizer 10 | 11 | NUM_CLASSES = 3 12 | 13 | 14 | def getData(folder_path): 15 | labels = [] 16 | msgs = [] 17 | # 遍历文件夹 18 | for root, dirs, files in os.walk(folder_path): 19 | # 遍历当前文件夹下的所有文件 20 | for filename in files: 21 | # 判断是否为csv文件 22 | if filename.endswith(".csv"): 23 | file_path = os.path.join(root, filename) 24 | # 读取csv文件内容 25 | with open(file_path, 'r', errors='ignore') as csv_file: 26 | for line in csv_file: 27 | if line[0] == '' or line[0]==' ': 28 | continue 29 | labels.append([int(str.strip(line[0]))]) 30 | msgs.append(line[3:]) 31 | return np.array(msgs), np.array(labels) 32 | 33 | 34 | trainDataMsgs, trainDataLabels = getData("./trainData") 35 | testDataMsgs, testDataLabels = getData("./testData") 36 | 37 | 38 | # preparing data 39 | x_train = tf.constant(trainDataMsgs, dtype=tf.string) 40 | 41 | print(x_train.shape) 42 | 43 | y_train = np.zeros((len(x_train),NUM_CLASSES)) 44 | for idx, ex in enumerate(trainDataLabels): 45 | for val in ex: 46 | y_train[idx][val] = 1 47 | 48 | 49 | # test data 50 | x_test = tf.constant(testDataMsgs, dtype=tf.string) 51 | y_test = np.zeros((len(x_test),NUM_CLASSES)) 52 | for idx, ex in enumerate(testDataLabels): 53 | for val in ex: 54 | y_test[idx][val] = 1 55 | 56 | 57 | # using strings directly requires to put a shape of (1,) and dtype tf.string 58 | inputs = layers.Input(shape=(1,), name="token", dtype=tf.string) 59 | 60 | # add RETVec tokenizer layer with default settings -- this is all you have to do to build a model with RETVec! 61 | x = RETVecTokenizer(model='retvec-v1')(inputs) 62 | 63 | # standard two layer LSTM 64 | x = layers.Bidirectional(layers.LSTM(64, return_sequences=True))(x) 65 | x = layers.Bidirectional(layers.LSTM(64))(x) 66 | outputs = layers.Dense(NUM_CLASSES, activation='sigmoid')(x) 67 | model = tf.keras.Model(inputs, outputs) 68 | model.summary() 69 | 70 | # compile and train the model 71 | batch_size = 256 72 | epochs = 2 73 | model.compile('adam', 'binary_crossentropy', ['acc']) 74 | history = model.fit(x_train, y_train, epochs=epochs, batch_size=batch_size, 75 | validation_data=(x_test, y_test)) 76 | 77 | # saving the model 78 | save_path = './emotion_model/1' 79 | model.save(save_path) 80 | -------------------------------------------------------------------------------- /server/hooks/spam_block/trec06c_format.py: -------------------------------------------------------------------------------- 1 | import os 2 | from email.parser import Parser 3 | from email.policy import default 4 | 5 | # 该脚本用于整理trec06c数据集,可以生成训练集和测试集数据格式 6 | 7 | def getData(path): 8 | f = open(path, 'r', encoding='gb2312', errors='ignore') 9 | data = f.read() 10 | headers = Parser(policy=default).parsestr(data) 11 | body = headers.get_payload() 12 | body = body.replace("\n", "") 13 | 14 | return headers["subject"], body 15 | 16 | 17 | num = 0 18 | 19 | # getData("../data/000/000") 20 | with open("index", "r") as f: 21 | with open("trec06c_train.csv", "w") as w: 22 | with open("trec06c_test.csv", "w") as wt: 23 | while True: 24 | line = f.readline() 25 | if not line: 26 | break 27 | infos = line.split(" ") 28 | subject, body = getData(infos[1].strip()) 29 | tp = 0 30 | if infos[0].lower() == "spam": 31 | tp = 1 32 | data = "{} \t{} {}\n".format(tp, subject, body) 33 | if num < 55000: 34 | w.write(data) 35 | else: 36 | wt.write(data) 37 | num += 1 38 | print(num) -------------------------------------------------------------------------------- /server/hooks/spam_block/trec07p_format.py: -------------------------------------------------------------------------------- 1 | import os 2 | from email.parser import Parser 3 | from email.policy import default 4 | from bs4 import BeautifulSoup 5 | 6 | 7 | # 该脚本用于整理trec06c数据集,可以生成训练集和测试集数据格式 8 | 9 | def getData(path): 10 | f = open(path, 'r', errors='ignore') 11 | data = f.read() 12 | headers = Parser(policy=default).parsestr(data) 13 | body = "" 14 | if headers.is_multipart(): 15 | for part in headers.iter_parts(): 16 | tbody = part.get_payload() 17 | if isinstance(tbody, list): 18 | for item in tbody: 19 | txt = item.get_payload() 20 | if isinstance(tbody, list): 21 | return "", "" 22 | bsObj = BeautifulSoup(txt, 'lxml') 23 | body += bsObj.get_text() 24 | else: 25 | bsObj = BeautifulSoup(tbody, 'lxml') 26 | body += bsObj.get_text() 27 | else: 28 | tbody = headers.get_payload() 29 | bsObj = BeautifulSoup(tbody, 'lxml') 30 | body += bsObj.get_text() 31 | return headers["subject"], body.replace("\n", "") 32 | 33 | 34 | num = 0 35 | 36 | # getData("../data/000/000") 37 | with open("index", "r") as f: 38 | with open("trec07p_train.csv", "w") as w: 39 | with open("trec07p_test.csv", "w") as wt: 40 | while True: 41 | line = f.readline() 42 | if not line: 43 | break 44 | infos = line.split(" ") 45 | subject, body = getData(infos[1].strip()) 46 | if subject == "": 47 | continue 48 | tp = 0 49 | if infos[0].lower() == "spam": 50 | tp = 1 51 | data = "{} \t{} {}\n".format(tp, subject, body) 52 | if num < 55000: 53 | w.write(data) 54 | else: 55 | wt.write(data) 56 | num += 1 57 | print(num) 58 | -------------------------------------------------------------------------------- /server/hooks/telegram_push/README.md: -------------------------------------------------------------------------------- 1 | ## How To Ues 2 | 3 | Create bot and get token from [BotFather](https://t.me/BotFather) 4 | 5 | Copy plugin binary file to `/plugins` 6 | 7 | add config.json to `/plugins/config.com` like this: 8 | 9 | ```jsonc 10 | { 11 | "tgChatId": "", // telegram chatid 12 | "tgBotToken": "", // telegram token 13 | } 14 | 15 | ``` -------------------------------------------------------------------------------- /server/hooks/wechat_push/README.md: -------------------------------------------------------------------------------- 1 | ## How To Ues / 如何使用 2 | 3 | 4 | Copy plugin binary file to `/plugins` 5 | 6 | 复制插件二进制文件到`/plugins`文件夹 7 | 8 | add config.json to `/plugins/config.com` like this: 9 | 10 | 新建配置文件`/plugins/config.com`,内容如下 11 | 12 | ```jsonc 13 | { 14 | "weChatPushAppId": "", // wechat appid 15 | "weChatPushSecret": "", // weChat Secret 16 | "weChatPushTemplateId": "", // weChat TemplateId 17 | "weChatPushUserId": "", // weChat UserId 18 | } 19 | ``` 20 | 21 | WeChat Message Template : 22 | 23 | 微信推送模板设置: 24 | 25 | Template Title: New Email Notice 26 | 27 | 模板标题:新邮件提醒 28 | 29 | Template Content: {{Content.DATA}} 30 | 31 | 模板内容:{{Content.DATA}} -------------------------------------------------------------------------------- /server/i18n/i18n.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | var ( 4 | cn = map[string]string{ 5 | "all_email": "全部邮件数据", 6 | "inbox": "收件箱", 7 | "outbox": "发件箱", 8 | "sketch": "草稿箱", 9 | "aperror": "账号或密码错误", 10 | "unknowError": "未知错误", 11 | "succ": "成功", 12 | "send_fail": "发送失败", 13 | "att_err": "附件解码错误", 14 | "login_exp": "登录已失效", 15 | "ip_taps": "这是你服务器IP,确保这个IP正确", 16 | "invalid_email_address": "无效的邮箱地址!", 17 | "deleted": "垃圾箱", 18 | "junk": "广告箱", 19 | } 20 | en = map[string]string{ 21 | "all_email": "All Email", 22 | "inbox": "Inbox", 23 | "outbox": "Outbox", 24 | "sketch": "Sketch", 25 | "aperror": "Incorrect account number or password", 26 | "unknowError": "Unknow Error", 27 | "succ": "Success", 28 | "send_fail": "Send Failure", 29 | "att_err": "Attachment decoding error", 30 | "login_exp": "Login has expired.", 31 | "ip_taps": "This is your server's IP, make sure it is correct.", 32 | "invalid_email_address": "Invalid e-mail address!", 33 | "deleted": "Deleted", 34 | "junk": "Junk", 35 | } 36 | ) 37 | 38 | func GetText(lang, key string) string { 39 | if lang == "zhCn" { 40 | text, exist := cn[key] 41 | if !exist { 42 | return "" 43 | } 44 | return text 45 | } 46 | text, exist := en[key] 47 | if !exist { 48 | return "" 49 | } 50 | return text 51 | } 52 | -------------------------------------------------------------------------------- /server/listen/cron_server/ssl_update.go: -------------------------------------------------------------------------------- 1 | package cron_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/config" 5 | "github.com/Jinnrry/pmail/services/setup/ssl" 6 | "github.com/Jinnrry/pmail/signal" 7 | log "github.com/sirupsen/logrus" 8 | "time" 9 | ) 10 | 11 | var expiredTime time.Time 12 | 13 | func Start() { 14 | 15 | // 第一次启动,等待到初始化完成 16 | if config.Instance == nil || config.IsInit == false { 17 | for { 18 | time.Sleep(1 * time.Minute) 19 | if config.Instance != nil && config.IsInit { 20 | break 21 | } 22 | } 23 | } 24 | 25 | if config.Instance.SSLType == config.SSLTypeAutoHTTP || config.Instance.SSLType == config.SSLTypeAutoDNS { 26 | go sslUpdateLoop() 27 | } else { 28 | go sslCheck() 29 | } 30 | 31 | } 32 | 33 | // 每天检查一遍SSL证书是否更新,更新就重启 34 | func sslCheck() { 35 | var err error 36 | _, expiredTime, _, err = ssl.CheckSSLCrtInfo() 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | for { 42 | time.Sleep(24 * time.Hour) 43 | _, newExpTime, _, err := ssl.CheckSSLCrtInfo() 44 | if err != nil { 45 | log.Errorf("SSL Check Error! %+v", err) 46 | } 47 | if newExpTime != expiredTime { 48 | expiredTime = newExpTime 49 | log.Infoln("SSL certificate had update! restarting") 50 | signal.RestartChan <- true 51 | } 52 | 53 | } 54 | } 55 | 56 | // 每天检查一遍SSL证书是否即将过期,即将过期就重新生成 57 | func sslUpdateLoop() { 58 | for { 59 | ssl.Update(true) 60 | // 每24小时检测一次证书有效期 61 | time.Sleep(24 * time.Hour) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /server/listen/http_server/http_server.go: -------------------------------------------------------------------------------- 1 | package http_server 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/Jinnrry/pmail/config" 7 | "github.com/Jinnrry/pmail/controllers" 8 | "github.com/Jinnrry/pmail/controllers/email" 9 | "github.com/Jinnrry/pmail/session" 10 | log "github.com/sirupsen/logrus" 11 | "io/fs" 12 | "net/http" 13 | "time" 14 | ) 15 | 16 | // 这个服务是为了拦截http请求转发到https 17 | var httpServer *http.Server 18 | 19 | func HttpStop() { 20 | if httpServer != nil { 21 | httpServer.Close() 22 | } 23 | } 24 | 25 | func router(mux *http.ServeMux) { 26 | fe, err := fs.Sub(local, "dist") 27 | if err != nil { 28 | panic(err) 29 | } 30 | mux.Handle("/", http.FileServer(http.FS(fe))) 31 | // 挑战请求类似这样 /.well-known/acme-challenge/QPyMAyaWw9s5JvV1oruyqWHG7OqkHMJEHPoUz2046KM 32 | mux.HandleFunc("/.well-known/", controllers.AcmeChallenge) 33 | mux.HandleFunc("/api/ping", controllers.Ping) 34 | mux.HandleFunc("/api/login", contextIterceptor(controllers.Login)) 35 | mux.HandleFunc("/api/logout", contextIterceptor(controllers.Logout)) 36 | mux.HandleFunc("/api/group", contextIterceptor(controllers.GetUserGroup)) 37 | mux.HandleFunc("/api/group/list", contextIterceptor(controllers.GetUserGroupList)) 38 | mux.HandleFunc("/api/group/add", contextIterceptor(controllers.AddGroup)) 39 | mux.HandleFunc("/api/group/del", contextIterceptor(controllers.DelGroup)) 40 | mux.HandleFunc("/api/email/list", contextIterceptor(email.EmailList)) 41 | mux.HandleFunc("/api/email/del", contextIterceptor(email.EmailDelete)) 42 | mux.HandleFunc("/api/email/read", contextIterceptor(email.MarkRead)) 43 | mux.HandleFunc("/api/email/detail", contextIterceptor(email.EmailDetail)) 44 | mux.HandleFunc("/api/email/move", contextIterceptor(email.Move)) 45 | mux.HandleFunc("/api/email/send", contextIterceptor(email.Send)) 46 | mux.HandleFunc("/api/settings/modify_password", contextIterceptor(controllers.ModifyPassword)) 47 | mux.HandleFunc("/api/rule/get", contextIterceptor(controllers.GetRule)) 48 | mux.HandleFunc("/api/rule/add", contextIterceptor(controllers.UpsertRule)) 49 | mux.HandleFunc("/api/rule/update", contextIterceptor(controllers.UpsertRule)) 50 | mux.HandleFunc("/api/rule/del", contextIterceptor(controllers.DelRule)) 51 | mux.HandleFunc("/attachments/", contextIterceptor(controllers.GetAttachments)) 52 | mux.HandleFunc("/attachments/download/", contextIterceptor(controllers.Download)) 53 | mux.HandleFunc("/api/user/create", contextIterceptor(controllers.CreateUser)) 54 | mux.HandleFunc("/api/user/edit", contextIterceptor(controllers.EditUser)) 55 | mux.HandleFunc("/api/user/info", contextIterceptor(controllers.Info)) 56 | mux.HandleFunc("/api/user/list", contextIterceptor(controllers.UserList)) 57 | mux.HandleFunc("/api/plugin/settings/", contextIterceptor(controllers.SettingsHtml)) 58 | mux.HandleFunc("/api/plugin/list", contextIterceptor(controllers.GetPluginList)) 59 | } 60 | 61 | func HttpStart() { 62 | mux := http.NewServeMux() 63 | 64 | HttpPort := 80 65 | if config.Instance.HttpPort > 0 { 66 | HttpPort = config.Instance.HttpPort 67 | } 68 | 69 | if config.Instance.HttpsEnabled != 2 { 70 | mux.HandleFunc("/api/ping", controllers.Ping) 71 | mux.HandleFunc("/", controllers.Interceptor) 72 | httpServer = &http.Server{ 73 | Addr: fmt.Sprintf(":%d", HttpPort), 74 | Handler: mux, 75 | ReadTimeout: time.Second * 90, 76 | WriteTimeout: time.Second * 90, 77 | } 78 | } else { 79 | 80 | router(mux) 81 | 82 | log.Infof("HttpServer Start On Port :%d", HttpPort) 83 | httpServer = &http.Server{ 84 | Addr: fmt.Sprintf(":%d", HttpPort), 85 | Handler: session.Instance.LoadAndSave(mux), 86 | ReadTimeout: time.Second * 90, 87 | WriteTimeout: time.Second * 90, 88 | } 89 | } 90 | 91 | err := httpServer.ListenAndServe() 92 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 93 | panic(err) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /server/listen/http_server/https_server.go: -------------------------------------------------------------------------------- 1 | package http_server 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/Jinnrry/pmail/config" 8 | "github.com/Jinnrry/pmail/controllers" 9 | "github.com/Jinnrry/pmail/dto/response" 10 | "github.com/Jinnrry/pmail/i18n" 11 | "github.com/Jinnrry/pmail/models" 12 | "github.com/Jinnrry/pmail/session" 13 | "github.com/Jinnrry/pmail/utils/context" 14 | "github.com/Jinnrry/pmail/utils/id" 15 | olog "log" 16 | "net/http" 17 | "time" 18 | 19 | log "github.com/sirupsen/logrus" 20 | "github.com/spf13/cast" 21 | ) 22 | 23 | //go:embed dist/* 24 | var local embed.FS 25 | 26 | var httpsServer *http.Server 27 | 28 | type nullWrite struct { 29 | } 30 | 31 | func (w *nullWrite) Write(p []byte) (int, error) { 32 | return len(p), nil 33 | } 34 | 35 | func HttpsStart() { 36 | 37 | mux := http.NewServeMux() 38 | 39 | router(mux) 40 | 41 | // go http server会打一堆没用的日志,写一个空的日志处理器,屏蔽掉日志输出 42 | nullLog := olog.New(&nullWrite{}, "", olog.Ldate) 43 | 44 | HttpsPort := 443 45 | if config.Instance.HttpsPort > 0 { 46 | HttpsPort = config.Instance.HttpsPort 47 | } 48 | 49 | if config.Instance.HttpsEnabled != 2 { 50 | log.Infof("Https Server Start On Port :%d", HttpsPort) 51 | httpsServer = &http.Server{ 52 | Addr: fmt.Sprintf(":%d", HttpsPort), 53 | Handler: session.Instance.LoadAndSave(mux), 54 | ReadTimeout: time.Second * 90, 55 | WriteTimeout: time.Second * 90, 56 | ErrorLog: nullLog, 57 | } 58 | err := httpsServer.ListenAndServeTLS(config.Instance.SSLPublicKeyPath, config.Instance.SSLPrivateKeyPath) 59 | if err != nil { 60 | panic(err) 61 | } 62 | } 63 | } 64 | 65 | func HttpsStop() { 66 | if httpsServer != nil { 67 | httpsServer.Close() 68 | } 69 | } 70 | 71 | // 注入context 72 | func contextIterceptor(h controllers.HandlerFunc) http.HandlerFunc { 73 | return func(w http.ResponseWriter, r *http.Request) { 74 | if w.Header().Get("Content-Type") == "" { 75 | w.Header().Set("Content-Type", "application/json") 76 | } 77 | 78 | ctx := &context.Context{} 79 | ctx.Context = r.Context() 80 | ctx.SetValue(context.LogID, id.GenLogID()) 81 | lang := r.Header.Get("Lang") 82 | if lang == "" { 83 | lang = "en" 84 | } 85 | ctx.Lang = lang 86 | 87 | if config.IsInit { 88 | user := cast.ToString(session.Instance.Get(ctx, "user")) 89 | var userInfo *models.User 90 | if user != "" { 91 | _ = json.Unmarshal([]byte(user), &userInfo) 92 | } 93 | if userInfo != nil && userInfo.ID > 0 { 94 | ctx.UserID = userInfo.ID 95 | ctx.UserName = userInfo.Name 96 | ctx.UserAccount = userInfo.Account 97 | ctx.IsAdmin = userInfo.IsAdmin == 1 98 | } 99 | 100 | if ctx.UserID == 0 { 101 | if r.URL.Path != "/api/ping" && r.URL.Path != "/api/login" { 102 | response.NewErrorResponse(response.NeedLogin, i18n.GetText(ctx.Lang, "login_exp"), "").FPrint(w) 103 | return 104 | } 105 | } 106 | } else if r.URL.Path != "/api/setup" { 107 | response.NewErrorResponse(response.NeedSetup, "", "").FPrint(w) 108 | return 109 | } 110 | h(ctx, w, r) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /server/listen/http_server/setup_server.go: -------------------------------------------------------------------------------- 1 | package http_server 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/Jinnrry/pmail/config" 7 | "github.com/Jinnrry/pmail/controllers" 8 | "github.com/Jinnrry/pmail/utils/ip" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/spf13/cast" 11 | "io/fs" 12 | "net/http" 13 | "os" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | // 项目初始化引导用的服务,初始化引导结束后即退出 19 | var setupServer *http.Server 20 | 21 | func SetupStart() { 22 | mux := http.NewServeMux() 23 | fe, err := fs.Sub(local, "dist") 24 | if err != nil { 25 | panic(err) 26 | } 27 | mux.Handle("/", http.FileServer(http.FS(fe))) 28 | mux.HandleFunc("/api/", contextIterceptor(controllers.Setup)) 29 | // 挑战请求类似这样 /.well-known/acme-challenge/QPyMAyaWw9s5JvV1oruyqWHG7OqkHMJEHPoUz2046KM 30 | mux.HandleFunc("/.well-known/", controllers.AcmeChallenge) 31 | 32 | HttpPort := 80 33 | flag.IntVar(&HttpPort, "p", 80, "初始化阶段Http服务端口") 34 | flag.Parse() 35 | 36 | if HttpPort == 80 { 37 | envs := os.Environ() 38 | for _, env := range envs { 39 | if strings.HasPrefix(env, "setup_port=") { 40 | HttpPort = cast.ToInt(strings.TrimSpace(strings.ReplaceAll(env, "setup_port=", ""))) 41 | } 42 | } 43 | } 44 | 45 | if HttpPort <= 0 || HttpPort > 65535 { 46 | HttpPort = 80 47 | } 48 | 49 | config.Instance.SetSetupPort(HttpPort) 50 | log.Infof("HttpServer Start On Port :%d", HttpPort) 51 | if HttpPort == 80 { 52 | log.Infof("Please click http://%s to continue.\n", ip.GetIp()) 53 | } else { 54 | log.Infof("Please click http://%s:%d to continue.", ip.GetIp(), HttpPort) 55 | } 56 | 57 | setupServer = &http.Server{ 58 | Addr: fmt.Sprintf(":%d", HttpPort), 59 | Handler: mux, 60 | ReadTimeout: time.Second * 60, 61 | WriteTimeout: time.Second * 60, 62 | } 63 | err = setupServer.ListenAndServe() 64 | if err != nil && err != http.ErrServerClosed { 65 | panic(err) 66 | } 67 | } 68 | 69 | func SetupStop() { 70 | err := setupServer.Close() 71 | log.Infof("Setup End!") 72 | if err != nil { 73 | panic(err) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /server/listen/imap_server/imap_server.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "crypto/tls" 5 | "github.com/Jinnrry/pmail/config" 6 | "github.com/emersion/go-imap/v2" 7 | "github.com/emersion/go-imap/v2/imapserver" 8 | log "github.com/sirupsen/logrus" 9 | "os" 10 | ) 11 | 12 | var instanceTLS *imapserver.Server 13 | 14 | func Stop() { 15 | if instanceTLS != nil { 16 | instanceTLS.Close() 17 | instanceTLS = nil 18 | } 19 | } 20 | 21 | // StarTLS 启动TLS端口监听,不加密的代码就懒得写了 22 | func StarTLS() { 23 | 24 | crt, err := tls.LoadX509KeyPair(config.Instance.SSLPublicKeyPath, config.Instance.SSLPrivateKeyPath) 25 | if err != nil { 26 | panic(err) 27 | } 28 | tlsConfig := &tls.Config{ 29 | Certificates: []tls.Certificate{crt}, 30 | } 31 | 32 | memServer := NewServer() 33 | 34 | option := &imapserver.Options{ 35 | NewSession: func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { 36 | return memServer.NewSession(), nil, nil 37 | }, 38 | Caps: imap.CapSet{ 39 | imap.CapIMAP4rev1: {}, 40 | }, 41 | TLSConfig: tlsConfig, 42 | InsecureAuth: false, 43 | } 44 | 45 | if config.Instance.LogLevel == "debug" { 46 | option.DebugWriter = os.Stdout 47 | } 48 | 49 | instanceTLS = imapserver.New(option) 50 | log.Infof("IMAP With TLS Server Start On Port :993") 51 | if err := instanceTLS.ListenAndServeTLS(":993"); err != nil { 52 | panic(err) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server/listen/imap_server/server.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/utils/context" 5 | "github.com/Jinnrry/pmail/utils/id" 6 | "github.com/emersion/go-imap/v2" 7 | log "github.com/sirupsen/logrus" 8 | "sync" 9 | "time" 10 | 11 | "github.com/emersion/go-imap/v2/imapserver" 12 | ) 13 | 14 | // Server is a server instance. 15 | // 16 | // A server contains a list of users. 17 | type Server struct { 18 | mutex sync.Mutex 19 | } 20 | 21 | // NewServer creates a new server. 22 | func NewServer() *Server { 23 | return &Server{} 24 | } 25 | 26 | type Status int8 27 | 28 | const ( 29 | UNAUTHORIZED Status = 1 30 | AUTHORIZED Status = 2 31 | SELECTED Status = 3 32 | LOGOUT Status = 4 33 | ) 34 | 35 | type serverSession struct { 36 | server *Server // immutable 37 | ctx *context.Context 38 | status Status 39 | currentMailbox string 40 | connectTime time.Time 41 | deleteUidList []int 42 | } 43 | 44 | // NewSession creates a new IMAP session. 45 | func (s *Server) NewSession() imapserver.Session { 46 | tc := &context.Context{} 47 | tc.SetValue(context.LogID, id.GenLogID()) 48 | 49 | return &serverSession{ 50 | server: s, 51 | ctx: tc, 52 | connectTime: time.Now(), 53 | } 54 | } 55 | 56 | func (s *serverSession) Close() error { 57 | return nil 58 | } 59 | 60 | func (s *serverSession) Subscribe(mailbox string) error { 61 | return nil 62 | } 63 | 64 | func (s *serverSession) Unsubscribe(mailbox string) error { 65 | return nil 66 | } 67 | 68 | func (s *serverSession) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) { 69 | log.WithContext(s.ctx).Errorf("Append Not Implemented") 70 | return nil, nil 71 | } 72 | 73 | func (s *serverSession) Unselect() error { 74 | s.currentMailbox = "" 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_create.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/services/group" 5 | "github.com/emersion/go-imap/v2" 6 | "strings" 7 | ) 8 | 9 | func (s *serverSession) Create(mailbox string, options *imap.CreateOptions) error { 10 | groupPath := strings.Split(mailbox, "/") 11 | 12 | var parentId int 13 | for _, path := range groupPath { 14 | newGroup, err := group.CreateGroup(s.ctx, path, parentId) 15 | if err != nil { 16 | return &imap.Error{ 17 | Type: imap.StatusResponseTypeNo, 18 | Text: err.Error(), 19 | } 20 | } 21 | parentId = newGroup.ID 22 | } 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_delete.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/services/group" 5 | "github.com/emersion/go-imap/v2" 6 | "strings" 7 | ) 8 | 9 | func (s *serverSession) Delete(mailbox string) error { 10 | groupPath := strings.Split(mailbox, "/") 11 | 12 | groupName := groupPath[len(groupPath)-1] 13 | groupInfo, err := group.GetGroupByName(s.ctx, groupName) 14 | if err != nil { 15 | return &imap.Error{ 16 | Type: imap.StatusResponseTypeNo, 17 | Text: err.Error(), 18 | } 19 | } 20 | _, err = group.DelGroup(s.ctx, groupInfo.ID) 21 | if err != nil { 22 | return &imap.Error{ 23 | Type: imap.StatusResponseTypeNo, 24 | Text: err.Error(), 25 | } 26 | } 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_expunge.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/services/del_email" 5 | "github.com/emersion/go-imap/v2" 6 | "github.com/emersion/go-imap/v2/imapserver" 7 | "github.com/spf13/cast" 8 | ) 9 | 10 | func (s *serverSession) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) error { 11 | if uids == nil && len(s.deleteUidList) == 0 { 12 | return nil 13 | } 14 | uidList := []int{} 15 | 16 | if uids != nil { 17 | for _, uidRange := range *uids { 18 | if uidRange.Start > 0 && uidRange.Stop > 0 { 19 | for i := uidRange.Start; i <= uidRange.Stop; i++ { 20 | uidList = append(uidList, cast.ToInt(uint32(i))) 21 | } 22 | } 23 | } 24 | } 25 | 26 | if len(s.deleteUidList) > 0 { 27 | uidList = append(uidList, s.deleteUidList...) 28 | } 29 | 30 | if len(uidList) == 0 { 31 | return nil 32 | } 33 | 34 | err := del_email.DelByUID(s.ctx, uidList) 35 | s.deleteUidList = []int{} 36 | if err != nil { 37 | return &imap.Error{ 38 | Type: imap.StatusResponseTypeNo, 39 | Text: err.Error(), 40 | } 41 | } 42 | 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_idle.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/models" 5 | "github.com/Jinnrry/pmail/utils/context" 6 | "github.com/emersion/go-imap/v2/imapserver" 7 | "github.com/spf13/cast" 8 | "sync" 9 | ) 10 | 11 | var userConnects sync.Map 12 | 13 | func (s *serverSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error { 14 | connects, ok := userConnects.Load(s.ctx.UserID) 15 | logId := cast.ToString(s.ctx.GetValue(context.LogID)) 16 | 17 | if !ok { 18 | 19 | connects = map[string]*imapserver.UpdateWriter{ 20 | logId: w, 21 | } 22 | userConnects.Store(s.ctx.UserID, connects) 23 | } else { 24 | connects := connects.(map[string]*imapserver.UpdateWriter) 25 | if _, ok := connects[logId]; !ok { 26 | connects[logId] = w 27 | userConnects.Store(s.ctx.UserID, connects) 28 | } 29 | } 30 | 31 | go func() { 32 | <-stop 33 | userConnects.Delete(logId) 34 | }() 35 | 36 | return nil 37 | } 38 | 39 | func IdleNotice(ctx *context.Context, userId int, email *models.Email) error { 40 | if userId == 0 || email == nil || email.Id == 0 { 41 | return nil 42 | } 43 | 44 | connects, ok := userConnects.Load(userId) 45 | if ok { 46 | connects := connects.(map[string]*imapserver.UpdateWriter) 47 | for _, connect := range connects { 48 | connect.WriteNumMessages(1) 49 | } 50 | } 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_list.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/db" 5 | "github.com/Jinnrry/pmail/models" 6 | "github.com/Jinnrry/pmail/utils/context" 7 | "github.com/emersion/go-imap/v2" 8 | "github.com/emersion/go-imap/v2/imapserver" 9 | log "github.com/sirupsen/logrus" 10 | "strings" 11 | ) 12 | 13 | func matchGroup(ctx *context.Context, w *imapserver.ListWriter, basePath, pattern string) { 14 | var groups []*models.Group 15 | if basePath == "" && pattern == "*" { 16 | db.Instance.Table("group").Where("user_id=?", ctx.UserID).Find(&groups) 17 | //w.WriteList(&imap.ListData{ 18 | // Attrs: []imap.MailboxAttr{imap.MailboxAttrNoSelect, imap.MailboxAttrHasChildren}, 19 | // Delim: '/', 20 | // Mailbox: "[PMail]", 21 | //}) 22 | w.WriteList(&imap.ListData{ 23 | Attrs: []imap.MailboxAttr{imap.MailboxAttrHasNoChildren}, 24 | Delim: '/', 25 | Mailbox: "INBOX", 26 | }) 27 | w.WriteList(&imap.ListData{ 28 | Attrs: []imap.MailboxAttr{imap.MailboxAttrHasNoChildren, imap.MailboxAttrSent}, 29 | Delim: '/', 30 | Mailbox: "Sent Messages", 31 | }) 32 | w.WriteList(&imap.ListData{ 33 | Attrs: []imap.MailboxAttr{imap.MailboxAttrHasNoChildren, imap.MailboxAttrDrafts}, 34 | Delim: '/', 35 | Mailbox: "Drafts", 36 | }) 37 | 38 | w.WriteList(&imap.ListData{ 39 | Attrs: []imap.MailboxAttr{imap.MailboxAttrHasNoChildren, imap.MailboxAttrTrash}, 40 | Delim: '/', 41 | Mailbox: "Deleted Messages", 42 | }) 43 | w.WriteList(&imap.ListData{ 44 | Attrs: []imap.MailboxAttr{imap.MailboxAttrHasNoChildren, imap.MailboxAttrJunk}, 45 | Delim: '/', 46 | Mailbox: "Junk", 47 | }) 48 | } else { 49 | pattern = strings.ReplaceAll(pattern, "/*", "/%") 50 | 51 | db.Instance.Table("group").Where("user_id=? and full_path like ?", ctx.UserID, pattern).Find(&groups) 52 | 53 | } 54 | for _, group := range groups { 55 | 56 | data := &imap.ListData{ 57 | Attrs: []imap.MailboxAttr{}, 58 | Mailbox: group.Name, 59 | Delim: '/', 60 | } 61 | 62 | if hasChildren(ctx, group.ID) { 63 | data.Attrs = append(data.Attrs, imap.MailboxAttrHasChildren) 64 | } 65 | 66 | data.Mailbox = getLayerName(ctx, group, true) 67 | 68 | w.WriteList(data) 69 | 70 | } 71 | 72 | } 73 | 74 | func hasChildren(ctx *context.Context, id int) bool { 75 | var parent []*models.Group 76 | db.Instance.Table("group").Where("parent_id=?", id).Find(&parent) 77 | return len(parent) > 0 78 | } 79 | func getLayerName(ctx *context.Context, item *models.Group, allPath bool) string { 80 | if item.ParentId == 0 { 81 | return item.Name 82 | } 83 | var parent models.Group 84 | _, _ = db.Instance.Table("group").Where("id=?", item.ParentId).Get(&parent) 85 | if allPath { 86 | return getLayerName(ctx, &parent, allPath) + "/" + item.Name 87 | } 88 | return getLayerName(ctx, &parent, allPath) 89 | } 90 | 91 | func (s *serverSession) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error { 92 | log.WithContext(s.ctx).Debugf("imap server list, ref: %s ,patterns: %s ", ref, patterns) 93 | 94 | if ref == "" && len(patterns) == 0 { 95 | w.WriteList(&imap.ListData{ 96 | Attrs: []imap.MailboxAttr{imap.MailboxAttrNoSelect, imap.MailboxAttrHasChildren}, 97 | Delim: '/', 98 | Mailbox: "[PMail]", 99 | }) 100 | } 101 | for _, pattern := range patterns { 102 | matchGroup(s.ctx, w, ref, pattern) 103 | } 104 | 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_login.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/Jinnrry/pmail/db" 6 | "github.com/Jinnrry/pmail/models" 7 | "github.com/Jinnrry/pmail/utils/errors" 8 | "github.com/Jinnrry/pmail/utils/password" 9 | "github.com/emersion/go-imap/v2" 10 | log "github.com/sirupsen/logrus" 11 | "strings" 12 | ) 13 | 14 | func (s *serverSession) Login(username, pwd string) error { 15 | if strings.Contains(username, "@") { 16 | args := strings.Split(username, "@") 17 | username = args[0] 18 | } 19 | 20 | var user models.User 21 | 22 | encodePwd := password.Encode(pwd) 23 | 24 | _, err := db.Instance.Where("account =? and password =? and disabled = 0", username, encodePwd).Get(&user) 25 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 26 | log.WithContext(s.ctx).Errorf("%+v", err) 27 | } 28 | 29 | if user.ID > 0 { 30 | s.status = AUTHORIZED 31 | 32 | s.ctx.UserID = user.ID 33 | s.ctx.UserName = user.Name 34 | s.ctx.UserAccount = user.Account 35 | log.WithContext(s.ctx).Debug("Login successful") 36 | 37 | return nil 38 | } 39 | 40 | log.WithContext(s.ctx).Info("user not found") 41 | return &imap.Error{ 42 | Type: imap.StatusResponseTypeNo, 43 | Code: imap.ResponseCodeAuthenticationFailed, 44 | Text: "Invalid credentials (Failure)", 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_move.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/dto/response" 5 | "github.com/Jinnrry/pmail/services/group" 6 | "github.com/Jinnrry/pmail/services/list" 7 | "github.com/Jinnrry/pmail/utils/context" 8 | "github.com/emersion/go-imap/v2" 9 | "github.com/emersion/go-imap/v2/imapserver" 10 | "github.com/spf13/cast" 11 | ) 12 | 13 | func (s *serverSession) Move(w *imapserver.MoveWriter, numSet imap.NumSet, dest string) error { 14 | 15 | var emailList []*response.EmailResponseData 16 | 17 | switch numSet.(type) { 18 | case imap.SeqSet: 19 | seqSet := numSet.(imap.SeqSet) 20 | for _, seq := range seqSet { 21 | emailList = list.GetEmailListByGroup(s.ctx, s.currentMailbox, list.ImapListReq{ 22 | Star: cast.ToInt(seq.Start), 23 | End: cast.ToInt(seq.Stop), 24 | }, false) 25 | } 26 | case imap.UIDSet: 27 | uidSet := numSet.(imap.UIDSet) 28 | for _, uid := range uidSet { 29 | emailList = list.GetEmailListByGroup(s.ctx, s.currentMailbox, list.ImapListReq{ 30 | Star: cast.ToInt(uint32(uid.Start)), 31 | End: cast.ToInt(uint32(uid.Stop)), 32 | }, true) 33 | } 34 | } 35 | 36 | var mailIds []int 37 | for _, email := range emailList { 38 | mailIds = append(mailIds, email.Id) 39 | } 40 | 41 | if group.IsDefaultBox(dest) { 42 | return move2defaultbox(s.ctx, mailIds, dest) 43 | } else { 44 | return move2userbox(s.ctx, mailIds, dest) 45 | } 46 | 47 | } 48 | 49 | func move2defaultbox(ctx *context.Context, mailIds []int, dest string) error { 50 | err := group.Move2DefaultBox(ctx, mailIds, dest) 51 | if err != nil { 52 | return &imap.Error{ 53 | Type: imap.StatusResponseTypeNo, 54 | Text: err.Error(), 55 | } 56 | } 57 | return nil 58 | } 59 | 60 | func move2userbox(ctx *context.Context, mailIds []int, dest string) error { 61 | groupInfo, err := group.GetGroupByFullPath(ctx, dest) 62 | if err != nil { 63 | return &imap.Error{ 64 | Type: imap.StatusResponseTypeNo, 65 | Text: err.Error(), 66 | } 67 | } 68 | if groupInfo == nil || groupInfo.ID == 0 { 69 | return &imap.Error{ 70 | Type: imap.StatusResponseTypeNo, 71 | Text: "Group not found", 72 | } 73 | } 74 | 75 | group.MoveMailToGroup(ctx, mailIds, groupInfo.ID) 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_namespace.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/emersion/go-imap/v2" 5 | ) 6 | 7 | func (s *serverSession) Namespace() (*imap.NamespaceData, error) { 8 | return &imap.NamespaceData{ 9 | Personal: []imap.NamespaceDescriptor{ 10 | { 11 | Prefix: "", 12 | Delim: '/', 13 | }, 14 | }, 15 | }, nil 16 | } 17 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_poll.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/db" 5 | "github.com/Jinnrry/pmail/models" 6 | "github.com/emersion/go-imap/v2/imapserver" 7 | "github.com/spf13/cast" 8 | ) 9 | 10 | func (s *serverSession) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error { 11 | 12 | var ue []models.UserEmail 13 | db.Instance.Table("user_email").Where("user_id=? and create >=?", s.ctx.UserID, s.connectTime).Find(&ue) 14 | 15 | if len(ue) > 0 { 16 | w.WriteNumMessages(cast.ToUint32(len(ue))) 17 | } 18 | 19 | return nil 20 | } 21 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_rename.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/services/group" 5 | "github.com/emersion/go-imap/v2" 6 | "strings" 7 | ) 8 | 9 | func (s *serverSession) Rename(mailbox, newName string) error { 10 | if group.IsDefaultBox(mailbox) { 11 | return &imap.Error{ 12 | Type: imap.StatusResponseTypeNo, 13 | Text: "This mailbox does not support rename.", 14 | } 15 | } 16 | 17 | groupPath := strings.Split(mailbox, "/") 18 | 19 | oldGroupName := groupPath[len(groupPath)-1] 20 | 21 | newGroupPath := strings.Split(newName, "/") 22 | 23 | newGroupName := newGroupPath[len(newGroupPath)-1] 24 | 25 | err := group.Rename(s.ctx, oldGroupName, newGroupName) 26 | 27 | if err != nil { 28 | return &imap.Error{ 29 | Type: imap.StatusResponseTypeNo, 30 | Text: err.Error(), 31 | } 32 | } 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_search.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/dto/response" 5 | "github.com/Jinnrry/pmail/services/list" 6 | "github.com/emersion/go-imap/v2" 7 | "github.com/emersion/go-imap/v2/imapserver" 8 | "github.com/spf13/cast" 9 | ) 10 | 11 | func (s *serverSession) Search(kind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) { 12 | retList := []*response.UserEmailUIDData{} 13 | 14 | for _, uidSet := range criteria.UID { 15 | for _, uid := range uidSet { 16 | res := list.GetUEListByUID(s.ctx, s.currentMailbox, cast.ToInt(uint32(uid.Start)), cast.ToInt(uint32(uid.Stop)), nil) 17 | retList = append(retList, res...) 18 | } 19 | } 20 | ret := &imap.SearchData{} 21 | 22 | if kind == imapserver.NumKindSeq { 23 | idList := imap.SeqSet{} 24 | for _, data := range retList { 25 | idList = append(idList, imap.SeqRange{ 26 | Start: cast.ToUint32(data.SerialNumber), 27 | Stop: cast.ToUint32(data.SerialNumber), 28 | }) 29 | } 30 | ret.All = idList 31 | ret.Count = uint32(len(retList)) 32 | } else { 33 | idList := imap.UIDSet{} 34 | for _, data := range retList { 35 | idList = append(idList, imap.UIDRange{ 36 | Start: imap.UID(data.ID), 37 | Stop: imap.UID(data.ID), 38 | }) 39 | } 40 | ret.UID = true 41 | ret.All = idList 42 | ret.Count = uint32(len(retList)) 43 | } 44 | return ret, nil 45 | } 46 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_select.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/services/group" 5 | "github.com/emersion/go-imap/v2" 6 | "github.com/spf13/cast" 7 | "strings" 8 | ) 9 | 10 | func (s *serverSession) Select(mailbox string, options *imap.SelectOptions) (*imap.SelectData, error) { 11 | if "" == mailbox { 12 | return nil, &imap.Error{ 13 | Type: imap.StatusResponseTypeBad, 14 | Text: "mailbox not found", 15 | } 16 | } 17 | 18 | paths := strings.Split(mailbox, "/") 19 | s.currentMailbox = strings.Trim(paths[len(paths)-1], `"`) 20 | _, data := group.GetGroupStatus(s.ctx, s.currentMailbox, []string{"MESSAGES", "UNSEEN", "UIDNEXT", "UIDVALIDITY"}) 21 | 22 | ret := &imap.SelectData{ 23 | Flags: []imap.Flag{imap.FlagSeen}, 24 | PermanentFlags: []imap.Flag{imap.FlagSeen}, 25 | NumMessages: cast.ToUint32(data["MESSAGES"]), 26 | UIDNext: imap.UID(data["UIDNEXT"]), 27 | UIDValidity: cast.ToUint32(data["UIDVALIDITY"]), 28 | } 29 | 30 | return ret, nil 31 | 32 | } 33 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_status.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/services/group" 5 | "github.com/emersion/go-imap/v2" 6 | "github.com/spf13/cast" 7 | ) 8 | 9 | func (s *serverSession) Status(mailbox string, options *imap.StatusOptions) (*imap.StatusData, error) { 10 | category := []string{} 11 | if options.UIDNext { 12 | category = append(category, "UIDNEXT") 13 | } 14 | if options.NumMessages { 15 | category = append(category, "MESSAGES") 16 | } 17 | if options.UIDValidity { 18 | category = append(category, "UIDVALIDITY") 19 | } 20 | if options.NumUnseen { 21 | category = append(category, "UNSEEN") 22 | } 23 | 24 | _, data := group.GetGroupStatus(s.ctx, mailbox, category) 25 | 26 | numMessages := cast.ToUint32(data["MESSAGES"]) 27 | numUnseen := cast.ToUint32(data["UNSEEN"]) 28 | numValidity := cast.ToUint32(data["UIDVALIDITY"]) 29 | numUIDNext := cast.ToUint32(data["UIDNEXT"]) 30 | 31 | ret := &imap.StatusData{ 32 | Mailbox: mailbox, 33 | NumMessages: &numMessages, 34 | UIDNext: imap.UID(numUIDNext), 35 | UIDValidity: numValidity, 36 | NumUnseen: &numUnseen, 37 | } 38 | 39 | return ret, nil 40 | } 41 | -------------------------------------------------------------------------------- /server/listen/imap_server/session_store.go: -------------------------------------------------------------------------------- 1 | package imap_server 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/dto/response" 5 | "github.com/Jinnrry/pmail/services/detail" 6 | "github.com/Jinnrry/pmail/services/list" 7 | "github.com/Jinnrry/pmail/utils/array" 8 | "github.com/emersion/go-imap/v2" 9 | "github.com/emersion/go-imap/v2/imapserver" 10 | "github.com/spf13/cast" 11 | ) 12 | 13 | func (s *serverSession) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error { 14 | 15 | if flags.Op == imap.StoreFlagsSet { 16 | return nil 17 | } 18 | 19 | var emailList []*response.EmailResponseData 20 | 21 | switch numSet.(type) { 22 | case imap.SeqSet: 23 | seqSet := numSet.(imap.SeqSet) 24 | for _, seq := range seqSet { 25 | res := list.GetEmailListByGroup(s.ctx, s.currentMailbox, list.ImapListReq{ 26 | Star: cast.ToInt(seq.Start), 27 | End: cast.ToInt(seq.Stop), 28 | }, false) 29 | emailList = append(emailList, res...) 30 | } 31 | 32 | case imap.UIDSet: 33 | uidSet := numSet.(imap.UIDSet) 34 | for _, uid := range uidSet { 35 | res := list.GetEmailListByGroup(s.ctx, s.currentMailbox, list.ImapListReq{ 36 | Star: cast.ToInt(uint32(uid.Start)), 37 | End: cast.ToInt(uint32(uid.Stop)), 38 | }, true) 39 | emailList = append(emailList, res...) 40 | } 41 | } 42 | 43 | if array.InArray(imap.FlagSeen, flags.Flags) && flags.Op == imap.StoreFlagsAdd { 44 | for _, data := range emailList { 45 | detail.MakeRead(s.ctx, data.Id, flags.Op == imap.StoreFlagsAdd) 46 | } 47 | } 48 | 49 | if array.InArray(imap.FlagDeleted, flags.Flags) && flags.Op == imap.StoreFlagsAdd { 50 | for _, data := range emailList { 51 | s.deleteUidList = append(s.deleteUidList, data.UeId) 52 | } 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /server/listen/pop3_server/action_test.go: -------------------------------------------------------------------------------- 1 | package pop3_server 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/Jinnrry/gopop" 7 | "github.com/Jinnrry/pmail/config" 8 | "github.com/Jinnrry/pmail/db" 9 | "github.com/Jinnrry/pmail/utils/context" 10 | "github.com/emersion/go-message/mail" 11 | "io" 12 | "testing" 13 | ) 14 | 15 | func Test_action_Retr(t *testing.T) { 16 | config.Init() 17 | config.Instance.DbType = config.DBTypeSQLite 18 | config.Instance.DbDSN = config.ROOT_PATH + "./config/pmail_temp.db" 19 | db.Init("") 20 | 21 | a := action{} 22 | session := &gopop.Session{ 23 | Ctx: &context.Context{ 24 | UserID: 1, 25 | }, 26 | } 27 | got, got1, err := a.Retr(session, 301) 28 | 29 | _, _, _ = got, got1, err 30 | } 31 | 32 | func Test_email(t *testing.T) { 33 | var b bytes.Buffer 34 | 35 | // Create our mail header 36 | var h mail.Header 37 | 38 | // Create a new mail writer 39 | mw, _ := mail.CreateWriter(&b, h) 40 | 41 | // Create a text part 42 | tw, _ := mw.CreateInline() 43 | 44 | var html mail.InlineHeader 45 | 46 | html.Header.Set("Content-Transfer-Encoding", "base64") 47 | w, _ := tw.CreatePart(html) 48 | 49 | io.WriteString(w, "=") 50 | 51 | w.Close() 52 | 53 | tw.Close() 54 | 55 | fmt.Printf("%s", b.String()) 56 | 57 | } 58 | -------------------------------------------------------------------------------- /server/listen/pop3_server/pop3server.go: -------------------------------------------------------------------------------- 1 | package pop3_server 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/tls" 6 | "github.com/Jinnrry/gopop" 7 | "github.com/Jinnrry/pmail/config" 8 | log "github.com/sirupsen/logrus" 9 | "time" 10 | ) 11 | 12 | var instance *gopop.Server 13 | var instanceTls *gopop.Server 14 | 15 | func StartWithTls() { 16 | crt, err := tls.LoadX509KeyPair(config.Instance.SSLPublicKeyPath, config.Instance.SSLPrivateKeyPath) 17 | if err != nil { 18 | panic(err) 19 | } 20 | tlsConfig := &tls.Config{} 21 | tlsConfig.Certificates = []tls.Certificate{crt} 22 | tlsConfig.Time = time.Now 23 | tlsConfig.Rand = rand.Reader 24 | instanceTls = gopop.NewPop3Server(995, "pop."+config.Instance.Domain, true, tlsConfig, action{}) 25 | instanceTls.ConnectAliveTime = 5 * time.Minute 26 | 27 | log.Infof("POP3 With TLS Server Start On Port :995") 28 | 29 | err = instanceTls.Start() 30 | if err != nil { 31 | panic(err) 32 | } 33 | } 34 | 35 | func Start() { 36 | crt, err := tls.LoadX509KeyPair(config.Instance.SSLPublicKeyPath, config.Instance.SSLPrivateKeyPath) 37 | if err != nil { 38 | panic(err) 39 | } 40 | tlsConfig := &tls.Config{} 41 | tlsConfig.Certificates = []tls.Certificate{crt} 42 | tlsConfig.Time = time.Now 43 | tlsConfig.Rand = rand.Reader 44 | instance = gopop.NewPop3Server(110, "pop."+config.Instance.Domain, false, tlsConfig, action{}) 45 | instance.ConnectAliveTime = 5 * time.Minute 46 | log.Infof("POP3 Server Start On Port :110") 47 | 48 | err = instance.Start() 49 | if err != nil { 50 | panic(err) 51 | } 52 | } 53 | 54 | func Stop() { 55 | if instance != nil { 56 | instance.Stop() 57 | } 58 | 59 | if instanceTls != nil { 60 | instanceTls.Stop() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /server/listen/smtp_server/action.go: -------------------------------------------------------------------------------- 1 | package smtp_server 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/Jinnrry/pmail/db" 6 | "github.com/Jinnrry/pmail/models" 7 | "github.com/Jinnrry/pmail/utils/context" 8 | "github.com/Jinnrry/pmail/utils/errors" 9 | "github.com/Jinnrry/pmail/utils/id" 10 | "github.com/Jinnrry/pmail/utils/password" 11 | "github.com/emersion/go-sasl" 12 | "github.com/emersion/go-smtp" 13 | log "github.com/sirupsen/logrus" 14 | "net" 15 | "strings" 16 | ) 17 | 18 | // The Backend implements SMTP server methods. 19 | type Backend struct{} 20 | 21 | func (bkd *Backend) NewSession(conn *smtp.Conn) (smtp.Session, error) { 22 | 23 | remoteAddress := conn.Conn().RemoteAddr() 24 | ctx := &context.Context{} 25 | ctx.SetValue(context.LogID, id.GenLogID()) 26 | log.WithContext(ctx).Debugf("新SMTP连接") 27 | 28 | return &Session{ 29 | RemoteAddress: remoteAddress, 30 | Ctx: ctx, 31 | }, nil 32 | } 33 | 34 | // A Session is returned after EHLO. 35 | type Session struct { 36 | RemoteAddress net.Addr 37 | User string 38 | From string 39 | To []string 40 | Ctx *context.Context 41 | } 42 | 43 | // AuthMechanisms returns a slice of available auth mechanisms 44 | // supported in this example. 45 | func (s *Session) AuthMechanisms() []string { 46 | return []string{sasl.Plain, sasl.Login} 47 | } 48 | 49 | // Auth is the handler for supported authenticators. 50 | func (s *Session) Auth(mech string) (sasl.Server, error) { 51 | log.WithContext(s.Ctx).Debugf("Auth :%s", mech) 52 | if mech == sasl.Plain { 53 | return sasl.NewPlainServer(func(identity, username, password string) error { 54 | return s.AuthPlain(username, password) 55 | }), nil 56 | } 57 | 58 | if mech == sasl.Login { 59 | return NewLoginServer(func(username, password string) error { 60 | return s.AuthPlain(username, password) 61 | }), nil 62 | } 63 | 64 | return nil, errors.New("Auth Not Supported") 65 | } 66 | 67 | func (s *Session) AuthPlain(username, pwd string) error { 68 | log.WithContext(s.Ctx).Debugf("Auth %s %s", username, pwd) 69 | 70 | s.User = username 71 | 72 | var user models.User 73 | 74 | encodePwd := password.Encode(pwd) 75 | 76 | infos := strings.Split(username, "@") 77 | if len(infos) > 1 { 78 | username = infos[0] 79 | } 80 | 81 | _, err := db.Instance.Where("account =? and password =? and disabled=0", username, encodePwd).Get(&user) 82 | if err != nil && err != sql.ErrNoRows { 83 | log.Errorf("%+v", err) 84 | } 85 | 86 | if user.ID > 0 { 87 | s.Ctx.UserAccount = user.Account 88 | s.Ctx.UserID = user.ID 89 | s.Ctx.UserName = user.Name 90 | s.Ctx.IsAdmin = user.IsAdmin == 1 91 | 92 | log.WithContext(s.Ctx).Debugf("Auth Success %+v", user) 93 | return nil 94 | } 95 | 96 | log.WithContext(s.Ctx).Debugf("登陆错误%s %s", username, pwd) 97 | return errors.New("password error") 98 | } 99 | 100 | func (s *Session) Mail(from string, opts *smtp.MailOptions) error { 101 | log.WithContext(s.Ctx).Debugf("Mail Success %+v %+v", from, opts) 102 | s.From = from 103 | return nil 104 | } 105 | 106 | func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error { 107 | log.WithContext(s.Ctx).Debugf("Rcpt Success %+v", to) 108 | 109 | s.To = append(s.To, to) 110 | return nil 111 | } 112 | 113 | func (s *Session) Reset() {} 114 | 115 | func (s *Session) Logout() error { 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /server/listen/smtp_server/login.go: -------------------------------------------------------------------------------- 1 | package smtp_server 2 | 3 | import "github.com/emersion/go-sasl" 4 | 5 | // Authenticates users with an username and a password. 6 | type LoginAuthenticator func(username, password string) error 7 | 8 | type loginState int 9 | 10 | const ( 11 | loginNotStarted loginState = iota 12 | loginWaitingUsername 13 | loginWaitingPassword 14 | ) 15 | 16 | type loginServer struct { 17 | state loginState 18 | username, password string 19 | authenticate LoginAuthenticator 20 | } 21 | 22 | // A server implementation of the LOGIN authentication mechanism, as described 23 | // in https://tools.ietf.org/html/draft-murchison-sasl-login-00. 24 | // 25 | // LOGIN is obsolete and should only be enabled for legacy clients that cannot 26 | // be updated to use PLAIN. 27 | func NewLoginServer(authenticator LoginAuthenticator) sasl.Server { 28 | return &loginServer{authenticate: authenticator} 29 | } 30 | 31 | func (a *loginServer) Next(response []byte) (challenge []byte, done bool, err error) { 32 | switch a.state { 33 | case loginNotStarted: 34 | // Check for initial response field, as per RFC4422 section 3 35 | if response == nil { 36 | challenge = []byte("Username:") 37 | break 38 | } 39 | a.state++ 40 | fallthrough 41 | case loginWaitingUsername: 42 | a.username = string(response) 43 | challenge = []byte("Password:") 44 | case loginWaitingPassword: 45 | a.password = string(response) 46 | err = a.authenticate(a.username, a.password) 47 | done = true 48 | default: 49 | err = sasl.ErrUnexpectedClientResponse 50 | } 51 | 52 | a.state++ 53 | return 54 | } 55 | -------------------------------------------------------------------------------- /server/listen/smtp_server/smtp.go: -------------------------------------------------------------------------------- 1 | package smtp_server 2 | 3 | import ( 4 | "crypto/tls" 5 | "github.com/Jinnrry/pmail/config" 6 | "github.com/emersion/go-smtp" 7 | log "github.com/sirupsen/logrus" 8 | "time" 9 | ) 10 | 11 | var instance *smtp.Server 12 | var instanceTls *smtp.Server 13 | var instanceTlsNew *smtp.Server 14 | 15 | func StartWithTLSNew() { 16 | be := &Backend{} 17 | 18 | instanceTlsNew = smtp.NewServer(be) 19 | 20 | instanceTlsNew.Addr = ":587" 21 | instanceTlsNew.Domain = config.Instance.Domain 22 | instanceTlsNew.ReadTimeout = 10 * time.Second 23 | instanceTlsNew.WriteTimeout = 10 * time.Second 24 | instanceTlsNew.MaxMessageBytes = 1024 * 1024 * 30 25 | instanceTlsNew.MaxRecipients = 50 26 | // force TLS for auth 27 | instanceTlsNew.AllowInsecureAuth = true 28 | // Load the certificate and key 29 | cer, err := tls.LoadX509KeyPair(config.Instance.SSLPublicKeyPath, config.Instance.SSLPrivateKeyPath) 30 | if err != nil { 31 | log.Fatal(err) 32 | return 33 | } 34 | // Configure the TLS support 35 | instanceTlsNew.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}} 36 | 37 | log.Println("Starting Smtp With SSL Server Port:", instanceTlsNew.Addr) 38 | if err := instanceTlsNew.ListenAndServeTLS(); err != nil { 39 | log.Fatal(err) 40 | } 41 | } 42 | 43 | func StartWithTLS() { 44 | be := &Backend{} 45 | 46 | instanceTls = smtp.NewServer(be) 47 | 48 | instanceTls.Addr = ":465" 49 | instanceTls.Domain = config.Instance.Domain 50 | instanceTls.ReadTimeout = 10 * time.Second 51 | instanceTls.WriteTimeout = 10 * time.Second 52 | instanceTls.MaxMessageBytes = 1024 * 1024 * 30 53 | instanceTls.MaxRecipients = 50 54 | // force TLS for auth 55 | instanceTls.AllowInsecureAuth = true 56 | // Load the certificate and key 57 | cer, err := tls.LoadX509KeyPair(config.Instance.SSLPublicKeyPath, config.Instance.SSLPrivateKeyPath) 58 | if err != nil { 59 | log.Fatal(err) 60 | return 61 | } 62 | // Configure the TLS support 63 | instanceTls.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}} 64 | 65 | log.Println("Starting Smtp With SSL Server Port:", instanceTls.Addr) 66 | if err := instanceTls.ListenAndServeTLS(); err != nil { 67 | log.Fatal(err) 68 | } 69 | } 70 | 71 | func Start() { 72 | be := &Backend{} 73 | 74 | instance = smtp.NewServer(be) 75 | 76 | instance.Addr = ":25" 77 | instance.Domain = config.Instance.Domain 78 | instance.ReadTimeout = 10 * time.Second 79 | instance.WriteTimeout = 10 * time.Second 80 | instance.MaxMessageBytes = 1024 * 1024 * 30 81 | instance.MaxRecipients = 50 82 | // force TLS for auth 83 | instance.AllowInsecureAuth = false 84 | // Load the certificate and key 85 | cer, err := tls.LoadX509KeyPair(config.Instance.SSLPublicKeyPath, config.Instance.SSLPrivateKeyPath) 86 | if err != nil { 87 | log.Fatal(err) 88 | return 89 | } 90 | // Configure the TLS support 91 | instance.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}} 92 | 93 | log.Println("Starting Smtp Server Port:", instance.Addr) 94 | if err := instance.ListenAndServe(); err != nil { 95 | log.Fatal(err) 96 | } 97 | } 98 | 99 | func Stop() { 100 | if instance != nil { 101 | instance.Close() 102 | } 103 | if instanceTls != nil { 104 | instanceTls.Close() 105 | } 106 | 107 | if instanceTlsNew != nil { 108 | instanceTlsNew.Close() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /server/listen/smtp_server/smtp_test/sendEmailTest.py: -------------------------------------------------------------------------------- 1 | from email.mime.text import MIMEText 2 | import smtplib 3 | msg = MIMEText('hello, send by Python...', 'plain', 'utf-8') 4 | 5 | 6 | from_addr = "admin@domain.com" 7 | password = "admin" 8 | to_addr = "admin@domain.com" 9 | smtp_server = "127.0.0.1" 10 | 11 | server = smtplib.SMTP(smtp_server, 25) 12 | server.starttls() 13 | server.login(from_addr, password) 14 | server.sendmail(from_addr, [to_addr], msg.as_string()) 15 | server.quit() -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/config" 5 | "github.com/Jinnrry/pmail/listen/cron_server" 6 | "github.com/Jinnrry/pmail/res_init" 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | var ( 11 | gitHash string 12 | buildTime string 13 | goVersion string 14 | version string 15 | ) 16 | 17 | func main() { 18 | 19 | config.Init() 20 | 21 | if version == "" { 22 | version = "TestVersion" 23 | } 24 | 25 | log.Infoln("*******************************************************************") 26 | log.Infof("***\tServer Start Success \n") 27 | log.Infof("***\tServer Version: %s \n", version) 28 | log.Infof("***\tGit Commit Hash: %s ", gitHash) 29 | log.Infof("***\tBuild Date: %s ", buildTime) 30 | log.Infof("***\tBuild GoLang Version: %s ", goVersion) 31 | log.Infoln("*******************************************************************") 32 | 33 | // 定时任务启动 34 | go cron_server.Start() 35 | 36 | // 核心服务启动 37 | res_init.Init(version) 38 | 39 | log.Warnf("Server Stoped \n") 40 | 41 | } 42 | -------------------------------------------------------------------------------- /server/models/User.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type User struct { 4 | ID int `xorm:"id unsigned int not null pk autoincr"` 5 | Account string `xorm:"varchar(20) notnull unique comment('账号登陆名')"` 6 | Name string `xorm:"varchar(10) notnull comment('用户名')"` 7 | Password string `xorm:"char(32) notnull comment('登陆密码,两次md5加盐,md5(md5(password+pmail) +pmail2023)')" json:"-"` 8 | Disabled int `xorm:"disabled unsigned int not null default(0) comment('0启用,1禁用')"` 9 | IsAdmin int `xorm:"is_admin unsigned int not null default(0) comment('0不是管理员,1是管理员')"` 10 | } 11 | 12 | func (p User) TableName() string { 13 | return "user" 14 | } 15 | -------------------------------------------------------------------------------- /server/models/email.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "time" 7 | ) 8 | 9 | type Email struct { 10 | Id int `xorm:"id pk unsigned int autoincr notnull" json:"id"` 11 | Type int8 `xorm:"type tinyint(4) notnull default(0) comment('邮件类型,0:收到的邮件,1:发送的邮件')" json:"type"` 12 | Subject string `xorm:"subject varchar(1000) notnull default('') comment('邮件标题')" json:"subject"` 13 | ReplyTo string `xorm:"reply_to text comment('回复人')" json:"reply_to"` 14 | FromName string `xorm:"from_name varchar(50) notnull default('') comment('发件人名称')" json:"from_name"` 15 | FromAddress string `xorm:"from_address varchar(100) notnull default('') comment('发件人邮件地址')" json:"from_address"` 16 | To string `xorm:"to text comment('收件人地址')" json:"to"` 17 | Bcc string `xorm:"bcc text comment('密送')" json:"bcc"` 18 | Cc string `xorm:"cc text comment('抄送')" json:"cc"` 19 | Text sql.NullString `xorm:"text text comment('文本内容')" json:"text"` 20 | Html sql.NullString `xorm:"html mediumtext comment('html内容')" json:"html"` 21 | Sender string `xorm:"sender text comment('发送人')" json:"sender"` 22 | Attachments string `xorm:"attachments longtext comment('附件')" json:"attachments"` 23 | SPFCheck int8 `xorm:"spf_check tinyint(1) comment('spf校验是否通过')" json:"spf_check"` 24 | DKIMCheck int8 `xorm:"dkim_check tinyint(1) comment('dkim校验是否通过')" json:"dkim_check"` 25 | Status int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送,1已发送,2发送失败')" json:"status"` // 0未发送,1已发送,2发送失败 26 | CronSendTime time.Time `xorm:"cron_send_time comment('定时发送时间')" json:"cron_send_time"` 27 | UpdateTime time.Time `xorm:"update_time updated comment('更新时间')" json:"update_time"` 28 | SendUserID int `xorm:"send_user_id unsigned int notnull default(0) comment('发件人用户id')" json:"send_user_id"` 29 | Size int `xorm:"size unsigned int notnull default(1000) comment('邮件大小')" json:"size"` 30 | Error sql.NullString `xorm:"error text comment('投递错误信息')" json:"error"` 31 | SendDate time.Time `xorm:"send_date comment('投递时间')" json:"send_date"` 32 | CreateTime time.Time `xorm:"create_time created" json:"create_time"` 33 | } 34 | 35 | func (d *Email) TableName() string { 36 | return "email" 37 | } 38 | 39 | type attachments struct { 40 | Filename string 41 | ContentType string 42 | Index int 43 | //Content []byte 44 | } 45 | 46 | func (d *Email) MarshalJSON() ([]byte, error) { 47 | type Alias Email 48 | 49 | var allAtt = []attachments{} 50 | var showAtt = []attachments{} 51 | if d.Attachments != "" { 52 | _ = json.Unmarshal([]byte(d.Attachments), &allAtt) 53 | for i, att := range allAtt { 54 | att.Index = i 55 | //if att.ContentType == "application/octet-stream" { 56 | showAtt = append(showAtt, att) 57 | //} 58 | 59 | } 60 | } 61 | 62 | return json.Marshal(&struct { 63 | Alias 64 | CronSendTime string `json:"send_time"` 65 | SendDate string `json:"send_date"` 66 | UpdateTime string `json:"update_time"` 67 | CreateTime string `json:"create_time"` 68 | Text string `json:"text"` 69 | Html string `json:"html"` 70 | Error string `json:"error"` 71 | Attachments []attachments `json:"attachments"` 72 | }{ 73 | Alias: (Alias)(*d), 74 | CronSendTime: d.CronSendTime.Format("2006-01-02 15:04:05"), 75 | UpdateTime: d.UpdateTime.Format("2006-01-02 15:04:05"), 76 | CreateTime: d.CreateTime.Format("2006-01-02 15:04:05"), 77 | SendDate: d.SendDate.Format("2006-01-02 15:04:05"), 78 | Text: d.Text.String, 79 | Html: d.Html.String, 80 | Error: d.Error.String, 81 | Attachments: showAtt, 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /server/models/group.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Group struct { 4 | ID int `xorm:"id int unsigned not null pk autoincr" json:"id"` 5 | Name string `xorm:"varchar(10) notnull default('') comment('分组名称')" json:"name"` 6 | ParentId int `xorm:"parent_id int unsigned notnull default(0) comment('父分组名称')" json:"parent_id"` 7 | UserId int `xorm:"user_id int unsigned notnull default(0) comment('用户id')" json:"-"` 8 | FullPath string `xrom:"full_path varchar(600) comment('完整路径')" json:"full_path"` 9 | } 10 | 11 | const ( 12 | INBOX = 2000000000 13 | Sent = 2000000001 14 | Drafts = 2000000002 15 | Deleted = 2000000003 16 | Junk = 2000000004 17 | ) 18 | 19 | var GroupNameToCode = map[string]int{ 20 | "INBOX": INBOX, 21 | "Sent Messages": Sent, 22 | "Drafts": Drafts, 23 | "Deleted Messages": Deleted, 24 | "Junk": Junk, 25 | } 26 | 27 | var GroupCodeToName = map[int]string{ 28 | INBOX: "INBOX", 29 | Sent: "Sent Messages", 30 | Drafts: "Drafts", 31 | Deleted: "Deleted Messages", 32 | Junk: "Junk", 33 | } 34 | 35 | func (p *Group) TableName() string { 36 | return "group" 37 | } 38 | -------------------------------------------------------------------------------- /server/models/rule.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Rule struct { 4 | Id int `xorm:"id int unsigned not null pk autoincr" json:"id"` 5 | UserId int `xorm:"user_id notnull default(0) comment('用户id')" json:"user_id"` 6 | Name string `xorm:"name notnull default('') comment('规则名称')" json:"name"` 7 | Value string `xorm:"value text comment('规则内容')" json:"value"` 8 | Action int `xorm:"action notnull default(0) comment('执行动作,1已读,2转发,3删除')" json:"action"` 9 | Params string `xorm:"params notnull default('') comment('执行参数')" json:"params"` 10 | Sort int `xorm:"sort notnull default(0) comment('排序,越大约优先')" json:"sort"` 11 | } 12 | 13 | func (p *Rule) TableName() string { 14 | return "rule" 15 | } 16 | -------------------------------------------------------------------------------- /server/models/session.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Sessions struct { 4 | Token string `xorm:"token char(43) not null pk " json:"token"` 5 | Data string `xorm:"data blob" json:"data"` 6 | Expiry int `xorm:"expiry timestamp index" json:"expiry"` 7 | } 8 | 9 | func (p *Sessions) TableName() string { 10 | return "sessions" 11 | } 12 | -------------------------------------------------------------------------------- /server/models/user_email.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | type UserEmail struct { 6 | ID int `xorm:"id int unsigned not null pk autoincr"` 7 | UserID int `xorm:"user_id int not null index('idx_eid') index comment('用户id')"` 8 | EmailID int `xorm:"email_id not null index('idx_eid') index comment('信件id')"` 9 | IsRead int8 `xorm:"is_read tinyint(1) comment('是否已读')" json:"is_read"` 10 | GroupId int `xorm:"group_id int notnull default(0) comment('分组id')'" json:"group_id"` 11 | Status int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送或收件,1已发送,2发送失败,3删除 4草稿 5广告')" json:"status"` // 0未发送或收件,1已发送,2发送失败 3删除 4草稿箱(Drafts) 5骚扰邮件(Junk) 12 | Created time.Time `xorm:"create datetime created index('idx_create_time')"` 13 | } 14 | 15 | func (p UserEmail) TableName() string { 16 | return "user_email" 17 | } 18 | -------------------------------------------------------------------------------- /server/models/version.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Version struct { 4 | Id int `xorm:"id int unsigned not null pk autoincr" json:"id"` 5 | Info string `xorm:"varchar(255) notnull" json:"info"` 6 | } 7 | 8 | func (p *Version) TableName() string { 9 | return "version" 10 | } 11 | -------------------------------------------------------------------------------- /server/res_init/init.go: -------------------------------------------------------------------------------- 1 | package res_init 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/Jinnrry/pmail/config" 6 | "github.com/Jinnrry/pmail/db" 7 | "github.com/Jinnrry/pmail/dto/parsemail" 8 | "github.com/Jinnrry/pmail/hooks" 9 | "github.com/Jinnrry/pmail/listen/http_server" 10 | "github.com/Jinnrry/pmail/listen/imap_server" 11 | "github.com/Jinnrry/pmail/listen/pop3_server" 12 | "github.com/Jinnrry/pmail/listen/smtp_server" 13 | "github.com/Jinnrry/pmail/services/setup/ssl" 14 | "github.com/Jinnrry/pmail/session" 15 | "github.com/Jinnrry/pmail/signal" 16 | "github.com/Jinnrry/pmail/utils/file" 17 | log "github.com/sirupsen/logrus" 18 | "os" 19 | "time" 20 | ) 21 | 22 | func Init(serverVersion string) { 23 | 24 | if !config.IsInit { 25 | dirInit() 26 | 27 | go http_server.SetupStart() 28 | <-signal.InitChan 29 | http_server.SetupStop() 30 | } 31 | 32 | for { 33 | config.Init() 34 | // 启动前检查一遍证书 35 | ssl.Update(false) 36 | parsemail.Init() 37 | err := db.Init(serverVersion) 38 | if err != nil { 39 | panic(err) 40 | } 41 | session.Init() 42 | hooks.Init(serverVersion) 43 | // smtp server start 44 | go smtp_server.Start() 45 | go smtp_server.StartWithTLS() 46 | go smtp_server.StartWithTLSNew() 47 | // http server start 48 | go http_server.HttpsStart() 49 | go http_server.HttpStart() 50 | // pop3 server start 51 | go pop3_server.Start() 52 | go pop3_server.StartWithTls() 53 | // imap server start 54 | go imap_server.StarTLS() 55 | 56 | configStr, _ := json.Marshal(config.Instance) 57 | log.Warnf("Config File Info: %s", configStr) 58 | 59 | select { 60 | case <-signal.RestartChan: 61 | log.Infof("Server Restart!") 62 | smtp_server.Stop() 63 | http_server.HttpsStop() 64 | http_server.HttpStop() 65 | pop3_server.Stop() 66 | imap_server.Stop() 67 | hooks.Stop() 68 | case <-signal.StopChan: 69 | log.Infof("Server Stop!") 70 | smtp_server.Stop() 71 | http_server.HttpsStop() 72 | http_server.HttpStop() 73 | pop3_server.Stop() 74 | imap_server.Stop() 75 | hooks.Stop() 76 | return 77 | } 78 | log.Infof("Server Stop Success!") 79 | time.Sleep(5 * time.Second) 80 | 81 | } 82 | 83 | } 84 | 85 | func dirInit() { 86 | if !file.PathExist("./config") { 87 | err := os.MkdirAll("./config", 0744) 88 | if err != nil { 89 | panic(err) 90 | } 91 | } 92 | 93 | if !file.PathExist("./config/dkim") { 94 | err := os.MkdirAll("./config/dkim", 0744) 95 | if err != nil { 96 | panic(err) 97 | } 98 | } 99 | 100 | if !file.PathExist("./config/ssl") { 101 | err := os.MkdirAll("./config/ssl", 0744) 102 | if err != nil { 103 | panic(err) 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /server/services/attachments/attachments.go: -------------------------------------------------------------------------------- 1 | package attachments 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/Jinnrry/pmail/db" 6 | "github.com/Jinnrry/pmail/dto/parsemail" 7 | "github.com/Jinnrry/pmail/models" 8 | "github.com/Jinnrry/pmail/services/auth" 9 | "github.com/Jinnrry/pmail/utils/context" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | func GetAttachments(ctx *context.Context, emailId int, cid string) (string, []byte) { 14 | 15 | // 获取邮件内容 16 | var email models.Email 17 | _, err := db.Instance.ID(emailId).Get(&email) 18 | if err != nil { 19 | log.WithContext(ctx).Errorf("SQL error:%+v", err) 20 | return "", nil 21 | } 22 | 23 | // 检查权限 24 | if !auth.HasAuth(ctx, &email) { 25 | return "", nil 26 | } 27 | 28 | var atts []parsemail.Attachment 29 | _ = json.Unmarshal([]byte(email.Attachments), &atts) 30 | for _, att := range atts { 31 | if att.ContentID == cid { 32 | return att.ContentType, att.Content 33 | } 34 | } 35 | return "", nil 36 | } 37 | 38 | func GetAttachmentsByIndex(ctx *context.Context, emailId int, index int) (string, []byte) { 39 | 40 | // 获取邮件内容 41 | var email models.Email 42 | _, err := db.Instance.ID(emailId).Get(&email) 43 | if err != nil { 44 | log.WithContext(ctx).Errorf("SQL error:%+v", err) 45 | return "", nil 46 | } 47 | 48 | // 检查权限 49 | if !auth.HasAuth(ctx, &email) { 50 | return "", nil 51 | } 52 | 53 | var atts []parsemail.Attachment 54 | _ = json.Unmarshal([]byte(email.Attachments), &atts) 55 | 56 | if len(atts) > index { 57 | return atts[index].Filename, atts[index].Content 58 | } 59 | return "", nil 60 | } 61 | -------------------------------------------------------------------------------- /server/services/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "crypto" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/x509" 8 | "encoding/base64" 9 | "encoding/pem" 10 | "github.com/Jinnrry/pmail/db" 11 | "github.com/Jinnrry/pmail/models" 12 | "github.com/Jinnrry/pmail/utils/context" 13 | log "github.com/sirupsen/logrus" 14 | "os" 15 | "strings" 16 | ) 17 | 18 | // HasAuth 检查当前用户是否有某个邮件的auth 19 | func HasAuth(ctx *context.Context, email *models.Email) bool { 20 | if ctx.IsAdmin { 21 | return true 22 | } 23 | var ue []models.UserEmail 24 | err := db.Instance.Table(&models.UserEmail{}).Where("email_id = ? and user_id = ?", email.Id, ctx.UserID).Find(&ue) 25 | if err != nil { 26 | log.Errorf("Error while checking user: %v", err) 27 | return false 28 | } 29 | 30 | return len(ue) != 0 31 | } 32 | 33 | func DkimGen() string { 34 | privKeyStr, _ := os.ReadFile("./config/dkim/dkim.priv") 35 | publicKeyStr, _ := os.ReadFile("./config/dkim/dkim.public") 36 | if len(privKeyStr) > 0 && len(publicKeyStr) > 0 { 37 | return string(publicKeyStr) 38 | } 39 | 40 | var ( 41 | privKey crypto.Signer 42 | err error 43 | ) 44 | 45 | privKey, err = rsa.GenerateKey(rand.Reader, 1024) 46 | 47 | if err != nil { 48 | log.Fatalf("Failed to generate key: %v", err) 49 | } 50 | 51 | privBytes, err := x509.MarshalPKCS8PrivateKey(privKey) 52 | if err != nil { 53 | log.Fatalf("Failed to marshal private key: %v", err) 54 | } 55 | 56 | f, err := os.OpenFile("./config/dkim/dkim.priv", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) 57 | if err != nil { 58 | log.Fatalf("Failed to create key file: %v", err) 59 | } 60 | defer f.Close() 61 | 62 | privBlock := pem.Block{ 63 | Type: "PRIVATE KEY", 64 | Bytes: privBytes, 65 | } 66 | if err := pem.Encode(f, &privBlock); err != nil { 67 | log.Fatalf("Failed to write key PEM block: %v", err) 68 | } 69 | if err := f.Close(); err != nil { 70 | log.Fatalf("Failed to close key file: %v", err) 71 | } 72 | 73 | var pubBytes []byte 74 | 75 | switch pubKey := privKey.Public().(type) { 76 | case *rsa.PublicKey: 77 | // RFC 6376 is inconsistent about whether RSA public keys should 78 | // be formatted as RSAPublicKey or SubjectPublicKeyInfo. 79 | // Erratum 3017 (https://www.rfc-editor.org/errata/eid3017) 80 | // proposes allowing both. We use SubjectPublicKeyInfo for 81 | // consistency with other implementations including opendkim, 82 | // Gmail, and Fastmail. 83 | pubBytes, err = x509.MarshalPKIXPublicKey(pubKey) 84 | if err != nil { 85 | log.Fatalf("Failed to marshal public key: %v", err) 86 | } 87 | default: 88 | panic("unreachable") 89 | } 90 | 91 | params := []string{ 92 | "v=DKIM1", 93 | "k=rsa", 94 | "p=" + base64.StdEncoding.EncodeToString(pubBytes), 95 | } 96 | 97 | publicKey := strings.Join(params, "; ") 98 | 99 | os.WriteFile("./config/dkim/dkim.public", []byte(publicKey), 0666) 100 | 101 | return publicKey 102 | } 103 | -------------------------------------------------------------------------------- /server/services/del_email/del_email.go: -------------------------------------------------------------------------------- 1 | package del_email 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/consts" 5 | "github.com/Jinnrry/pmail/db" 6 | "github.com/Jinnrry/pmail/models" 7 | "github.com/Jinnrry/pmail/utils/context" 8 | log "github.com/sirupsen/logrus" 9 | "github.com/spf13/cast" 10 | "xorm.io/xorm" 11 | ) 12 | import . "xorm.io/builder" 13 | 14 | func DelEmail(ctx *context.Context, ids []int, forcedDel bool) error { 15 | session := db.Instance.NewSession() 16 | defer session.Close() 17 | if err := session.Begin(); err != nil { 18 | return err 19 | } 20 | for _, id := range ids { 21 | err := deleteOne(ctx, session, cast.ToInt64(id), forcedDel) 22 | if err != nil { 23 | session.Rollback() 24 | return err 25 | } 26 | } 27 | return session.Commit() 28 | } 29 | 30 | type num struct { 31 | Num int `xorm:"num"` 32 | } 33 | 34 | func deleteOne(ctx *context.Context, session *xorm.Session, id int64, forcedDel bool) error { 35 | if !forcedDel { 36 | _, err := session.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", id, ctx.UserID).Update(map[string]interface{}{ 37 | "status": consts.EmailStatusDel, 38 | "group_id": 0, 39 | }) 40 | return err 41 | } 42 | // 先删除关联关系 43 | var ue models.UserEmail 44 | _, err := session.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", id, ctx.UserID).Delete(&ue) 45 | if err != nil { 46 | return err 47 | } 48 | // 检查email是否还有人有权限 49 | var Num num 50 | _, err = session.Table(&models.UserEmail{}).Select("count(1) as num").Where("email_id=? ", id).Get(&Num) 51 | if err != nil { 52 | return err 53 | } 54 | if Num.Num == 0 { 55 | var email models.Email 56 | _, err = session.Table(&email).Where("id=?", id).Delete(&email) 57 | 58 | } 59 | return err 60 | } 61 | 62 | func DelByUID(ctx *context.Context, ids []int) error { 63 | session := db.Instance.NewSession() 64 | defer session.Close() 65 | for _, id := range ids { 66 | var ue models.UserEmail 67 | session.Table("user_email").Where(Eq{"id": ids, "user_id": ctx.UserID}).Get(&ue) 68 | if ue.ID == 0 { 69 | log.WithContext(ctx).Warn("no user email found") 70 | return nil 71 | } 72 | emailId := ue.EmailID 73 | 74 | // 先删除关联关系 75 | _, err := session.Table(&models.UserEmail{}).Where("id=? and user_id=?", id, ctx.UserID).Delete(&ue) 76 | if err != nil { 77 | session.Rollback() 78 | return err 79 | } 80 | 81 | // 检查email是否还有人有权限 82 | var Num num 83 | _, err = session.Table(&models.UserEmail{}).Select("count(1) as num").Where("email_id=? ", emailId).Get(&Num) 84 | if err != nil { 85 | return err 86 | } 87 | if Num.Num == 0 { 88 | var email models.Email 89 | _, err = session.Table(&email).Where("id=?", id).Delete(&email) 90 | 91 | } 92 | } 93 | session.Commit() 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /server/services/group/group_test.go: -------------------------------------------------------------------------------- 1 | package group 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Jinnrry/pmail/config" 6 | "github.com/Jinnrry/pmail/db" 7 | "github.com/Jinnrry/pmail/utils/context" 8 | "testing" 9 | ) 10 | 11 | func TestGetGroupStatus(t *testing.T) { 12 | config.Init() 13 | db.Init("") 14 | db.Instance.ShowSQL(true) 15 | ctx := &context.Context{ 16 | UserID: 1, 17 | UserName: "admin", 18 | UserAccount: "admin", 19 | } 20 | ret, _ := GetGroupStatus(ctx, "INBOX", []string{"MESSAGES", "UIDNEXT", "UIDVALIDITY", "UNSEEN"}) 21 | fmt.Println(ret) 22 | } 23 | -------------------------------------------------------------------------------- /server/services/list/list_test.go: -------------------------------------------------------------------------------- 1 | package list 2 | -------------------------------------------------------------------------------- /server/services/rule/match/base.go: -------------------------------------------------------------------------------- 1 | package match 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/dto/parsemail" 5 | "github.com/Jinnrry/pmail/utils/context" 6 | ) 7 | 8 | const ( 9 | RuleTypeRegex = "regex" 10 | RuleTypeContains = "contains" 11 | RuleTypeEq = "equal" 12 | ) 13 | 14 | type Match interface { 15 | Match(ctx *context.Context, email *parsemail.Email) bool 16 | } 17 | 18 | func buildUsers(users []*parsemail.User) string { 19 | ret := "" 20 | for i, u := range users { 21 | if i != 0 { 22 | ret += "," 23 | } 24 | ret += u.EmailAddress 25 | } 26 | return ret 27 | } 28 | 29 | func getFieldContent(field string, email *parsemail.Email) string { 30 | switch field { 31 | case "ReplyTo": 32 | return buildUsers(email.ReplyTo) 33 | case "From": 34 | return email.From.EmailAddress 35 | case "Subject": 36 | return email.Subject 37 | case "To": 38 | return buildUsers(email.To) 39 | case "Bcc": 40 | return buildUsers(email.Bcc) 41 | case "Cc": 42 | return buildUsers(email.Cc) 43 | case "Text": 44 | return string(email.Text) 45 | case "Html": 46 | return string(email.HTML) 47 | case "Sender": 48 | return email.Sender.EmailAddress 49 | case "Content": 50 | b := string(email.HTML) 51 | b2 := string(email.Text) 52 | return b + b2 53 | } 54 | return "" 55 | } 56 | -------------------------------------------------------------------------------- /server/services/rule/match/contains_match.go: -------------------------------------------------------------------------------- 1 | package match 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/dto/parsemail" 5 | "github.com/Jinnrry/pmail/utils/context" 6 | "strings" 7 | ) 8 | 9 | type ContainsMatch struct { 10 | Rule string 11 | Field string 12 | } 13 | 14 | func NewContainsMatch(field, rule string) *ContainsMatch { 15 | return &ContainsMatch{ 16 | Rule: rule, 17 | Field: field, 18 | } 19 | } 20 | 21 | func (r *ContainsMatch) Match(ctx *context.Context, email *parsemail.Email) bool { 22 | content := getFieldContent(r.Field, email) 23 | return strings.Contains(content, r.Rule) 24 | } 25 | -------------------------------------------------------------------------------- /server/services/rule/match/equal_match.go: -------------------------------------------------------------------------------- 1 | package match 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/dto/parsemail" 5 | "github.com/Jinnrry/pmail/utils/context" 6 | ) 7 | 8 | type EqualMatch struct { 9 | Rule string 10 | Field string 11 | } 12 | 13 | func NewEqualMatch(field, rule string) *EqualMatch { 14 | return &EqualMatch{ 15 | Rule: rule, 16 | Field: field, 17 | } 18 | } 19 | 20 | func (r *EqualMatch) Match(ctx *context.Context, email *parsemail.Email) bool { 21 | content := getFieldContent(r.Field, email) 22 | return content == r.Rule 23 | } 24 | -------------------------------------------------------------------------------- /server/services/rule/match/regex_match.go: -------------------------------------------------------------------------------- 1 | package match 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/dto/parsemail" 5 | "github.com/Jinnrry/pmail/utils/context" 6 | "github.com/dlclark/regexp2" 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | type RegexMatch struct { 11 | Rule string 12 | Field string 13 | } 14 | 15 | func NewRegexMatch(field, rule string) *RegexMatch { 16 | return &RegexMatch{ 17 | Rule: rule, 18 | Field: field, 19 | } 20 | } 21 | 22 | func (r *RegexMatch) Match(ctx *context.Context, email *parsemail.Email) bool { 23 | content := getFieldContent(r.Field, email) 24 | re := regexp2.MustCompile(r.Rule, 0) 25 | match, err := re.MatchString(content) 26 | 27 | if err != nil { 28 | log.WithContext(ctx).Errorf("rule regex error %v", err) 29 | } 30 | 31 | return match 32 | } 33 | -------------------------------------------------------------------------------- /server/services/rule/match/regex_match_test.go: -------------------------------------------------------------------------------- 1 | package match 2 | 3 | import ( 4 | "fmt" 5 | "github.com/dlclark/regexp2" 6 | "testing" 7 | ) 8 | 9 | func TestRegexMatch_Match(t *testing.T) { 10 | re := regexp2.MustCompile("^(?!.*abc\\.com).*", 0) 11 | match, err := re.MatchString("aa@abc.com") 12 | fmt.Println(match, err) 13 | } 14 | -------------------------------------------------------------------------------- /server/services/rule/rule.go: -------------------------------------------------------------------------------- 1 | package rule 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/config" 5 | "github.com/Jinnrry/pmail/consts" 6 | "github.com/Jinnrry/pmail/db" 7 | "github.com/Jinnrry/pmail/dto" 8 | "github.com/Jinnrry/pmail/dto/parsemail" 9 | "github.com/Jinnrry/pmail/models" 10 | "github.com/Jinnrry/pmail/services/rule/match" 11 | "github.com/Jinnrry/pmail/utils/context" 12 | "github.com/Jinnrry/pmail/utils/send" 13 | log "github.com/sirupsen/logrus" 14 | "github.com/spf13/cast" 15 | "strings" 16 | ) 17 | 18 | func GetAllRules(ctx *context.Context, userId int) []*dto.Rule { 19 | var res []*models.Rule 20 | var err error 21 | if userId == 0 { 22 | return nil 23 | } else { 24 | err = db.Instance.Where("user_id=?", userId).Decr("sort").Find(&res) 25 | } 26 | 27 | if err != nil { 28 | log.WithContext(ctx).Errorf("sqlERror :%v", err) 29 | } 30 | var ret []*dto.Rule 31 | for _, rule := range res { 32 | ret = append(ret, (&dto.Rule{}).Decode(rule)) 33 | } 34 | 35 | return ret 36 | } 37 | 38 | func MatchRule(ctx *context.Context, rule *dto.Rule, email *parsemail.Email) bool { 39 | 40 | for _, r := range rule.Rules { 41 | var m match.Match 42 | 43 | switch r.Type { 44 | case match.RuleTypeRegex: 45 | m = match.NewRegexMatch(r.Field, r.Rule) 46 | case match.RuleTypeContains: 47 | m = match.NewContainsMatch(r.Field, r.Rule) 48 | case match.RuleTypeEq: 49 | m = match.NewEqualMatch(r.Field, r.Rule) 50 | } 51 | if m == nil { 52 | continue 53 | } 54 | 55 | if !m.Match(ctx, email) { 56 | return false 57 | } 58 | } 59 | 60 | return true 61 | } 62 | 63 | func DoRule(ctx *context.Context, rule *dto.Rule, email *parsemail.Email, user *models.User) { 64 | log.WithContext(ctx).Debugf("执行规则:%s", rule.Name) 65 | 66 | switch rule.Action { 67 | case dto.READ: 68 | if email.MessageId > 0 { 69 | _, err := db.Instance.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", email.MessageId, rule.UserId).Cols("is_read").Update(map[string]interface{}{"is_read": 1}) 70 | if err != nil { 71 | log.WithContext(ctx).Errorf("sqlERror :%v", err) 72 | } 73 | } 74 | case dto.DELETE: 75 | _, err := db.Instance.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", email.MessageId, rule.UserId).Cols("status").Update(map[string]interface{}{"status": consts.EmailStatusDel}) 76 | if err != nil { 77 | log.WithContext(ctx).Errorf("sqlERror :%v", err) 78 | } 79 | case dto.FORWARD: 80 | if strings.Contains(rule.Params, config.Instance.Domain) { 81 | log.WithContext(ctx).Errorf("Forward Error! loop forwarding!") 82 | return 83 | } 84 | err := send.Forward(ctx, email, rule.Params, user) 85 | if err != nil { 86 | log.WithContext(ctx).Errorf("Forward Error:%v", err) 87 | } 88 | case dto.MOVE: 89 | _, err := db.Instance.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", email.MessageId, rule.UserId).Cols("group_id").Update(map[string]interface{}{"group_id": cast.ToInt(rule.Params)}) 90 | if err != nil { 91 | log.WithContext(ctx).Errorf("sqlERror :%v", err) 92 | } 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /server/services/setup/db.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/config" 5 | "github.com/Jinnrry/pmail/db" 6 | "github.com/Jinnrry/pmail/models" 7 | "github.com/Jinnrry/pmail/utils/array" 8 | "github.com/Jinnrry/pmail/utils/context" 9 | "github.com/Jinnrry/pmail/utils/errors" 10 | "github.com/Jinnrry/pmail/utils/password" 11 | ) 12 | 13 | func GetDatabaseSettings(ctx *context.Context) (string, string, error) { 14 | configData, err := config.ReadConfig() 15 | if err != nil { 16 | return "", "", errors.Wrap(err) 17 | } 18 | 19 | if configData.DbType == "" && configData.DbDSN == "" { 20 | return config.DBTypeSQLite, config.ROOT_PATH + "./config/pmail.db", nil 21 | } 22 | 23 | return configData.DbType, configData.DbDSN, nil 24 | } 25 | 26 | func GetAdminPassword(ctx *context.Context) (string, error) { 27 | 28 | users := []*models.User{} 29 | err := db.Instance.Find(&users) 30 | if err != nil { 31 | return "", errors.Wrap(err) 32 | } 33 | 34 | if len(users) > 0 { 35 | return users[0].Account, nil 36 | } 37 | 38 | return "", nil 39 | } 40 | 41 | func SetAdminPassword(ctx *context.Context, account, pwd string) error { 42 | encodePwd := password.Encode(pwd) 43 | var user models.User = models.User{ 44 | Account: account, 45 | Name: "admin", 46 | Password: encodePwd, 47 | IsAdmin: 1, 48 | } 49 | 50 | _, err := db.Instance.Insert(&user) 51 | if err != nil { 52 | return errors.Wrap(err) 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func SetDatabaseSettings(ctx *context.Context, dbType, dbDSN string) error { 59 | configData, err := config.ReadConfig() 60 | if err != nil { 61 | return errors.Wrap(err) 62 | } 63 | 64 | if !array.InArray(dbType, config.DBTypes) { 65 | return errors.New("dbtype error") 66 | } 67 | 68 | if dbDSN == "" { 69 | return errors.New("DSN error") 70 | } 71 | 72 | configData.DbType = dbType 73 | configData.DbDSN = dbDSN 74 | 75 | err = config.WriteConfig(configData) 76 | if err != nil { 77 | return errors.Wrap(err) 78 | } 79 | config.Init() 80 | // 检查数据库是否能正确连接 81 | err = db.Init("") 82 | if err != nil { 83 | return errors.Wrap(err) 84 | } 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /server/services/setup/dns.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Jinnrry/pmail/config" 6 | "strings" 7 | 8 | "github.com/Jinnrry/pmail/i18n" 9 | "github.com/Jinnrry/pmail/services/auth" 10 | "github.com/Jinnrry/pmail/utils/context" 11 | "github.com/Jinnrry/pmail/utils/errors" 12 | "github.com/Jinnrry/pmail/utils/ip" 13 | ) 14 | 15 | type DNSItem struct { 16 | Type string `json:"type"` 17 | Host string `json:"host"` 18 | Value string `json:"value"` 19 | TTL int `json:"ttl"` 20 | Tips string `json:"tips"` 21 | } 22 | 23 | func GetDNSSettings(ctx *context.Context) (map[string][]*DNSItem, error) { 24 | configData, err := config.ReadConfig() 25 | if err != nil { 26 | return nil, errors.Wrap(err) 27 | } 28 | 29 | ret := make(map[string][]*DNSItem) 30 | 31 | for _, domain := range configData.Domains { 32 | ret[domain] = []*DNSItem{ 33 | {Type: "A", Host: strings.ReplaceAll(configData.WebDomain, "."+configData.Domain, ""), Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")}, 34 | {Type: "A", Host: "smtp", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")}, 35 | {Type: "A", Host: "imap", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")}, 36 | {Type: "A", Host: "pop", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")}, 37 | {Type: "A", Host: "@", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")}, 38 | {Type: "MX", Host: "@", Value: fmt.Sprintf("smtp.%s", domain), TTL: 3600}, 39 | {Type: "TXT", Host: "@", Value: "v=spf1 a mx ~all", TTL: 3600}, 40 | {Type: "TXT", Host: "default._domainkey", Value: auth.DkimGen(), TTL: 3600}, 41 | } 42 | } 43 | 44 | return ret, nil 45 | } 46 | -------------------------------------------------------------------------------- /server/services/setup/domain.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/config" 5 | "github.com/Jinnrry/pmail/utils/array" 6 | "github.com/Jinnrry/pmail/utils/errors" 7 | "strings" 8 | ) 9 | 10 | func GetDomainSettings() (string, string, []string, error) { 11 | configData, err := config.ReadConfig() 12 | if err != nil { 13 | return "", "", []string{}, errors.Wrap(err) 14 | } 15 | 16 | return configData.Domain, configData.WebDomain, array.Difference(configData.Domains, []string{configData.Domain}), nil 17 | } 18 | 19 | func SetDomainSettings(smtpDomain, webDomain, multiDomains string) error { 20 | configData, err := config.ReadConfig() 21 | if err != nil { 22 | return errors.Wrap(err) 23 | } 24 | 25 | if smtpDomain == "" { 26 | return errors.New("domain must not empty!") 27 | } 28 | 29 | if webDomain == "" { 30 | return errors.New("web domain must not empty!") 31 | } 32 | 33 | configData.Domains = []string{} 34 | 35 | if multiDomains != "" { 36 | domains := strings.Split(multiDomains, ",") 37 | configData.Domains = domains 38 | } 39 | 40 | if !array.InArray(smtpDomain, configData.Domains) { 41 | configData.Domains = append(configData.Domains, smtpDomain) 42 | } 43 | 44 | configData.Domain = smtpDomain 45 | configData.WebDomain = webDomain 46 | 47 | // 检查域名是否指向本机 todo 48 | 49 | err = config.WriteConfig(configData) 50 | if err != nil { 51 | return errors.Wrap(err) 52 | } 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /server/services/setup/finish.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/config" 5 | "github.com/Jinnrry/pmail/signal" 6 | "github.com/Jinnrry/pmail/utils/errors" 7 | ) 8 | 9 | // Finish 标记初始化完成 10 | func Finish() error { 11 | cfg, err := config.ReadConfig() 12 | if err != nil { 13 | return errors.Wrap(err) 14 | } 15 | cfg.IsInit = true 16 | 17 | err = config.WriteConfig(cfg) 18 | if err != nil { 19 | return errors.Wrap(err) 20 | } 21 | // 初始化完成 22 | signal.InitChan <- true 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /server/services/setup/ssl/challenge.go: -------------------------------------------------------------------------------- 1 | package ssl 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/utils/context" 5 | "github.com/go-acme/lego/v4/challenge/dns01" 6 | log "github.com/sirupsen/logrus" 7 | "time" 8 | ) 9 | 10 | type authInfo struct { 11 | Domain string 12 | Token string 13 | KeyAuth string 14 | } 15 | 16 | type HttpChallenge struct { 17 | AuthInfo map[string]*authInfo 18 | } 19 | 20 | var instance *HttpChallenge 21 | 22 | func (h *HttpChallenge) Present(domain, token, keyAuth string) error { 23 | h.AuthInfo[token] = &authInfo{ 24 | Domain: domain, 25 | Token: token, 26 | KeyAuth: keyAuth, 27 | } 28 | 29 | return nil 30 | } 31 | 32 | func (h *HttpChallenge) CleanUp(domain, token, keyAuth string) error { 33 | delete(h.AuthInfo, token) 34 | return nil 35 | } 36 | 37 | func GetHttpChallengeInstance() *HttpChallenge { 38 | if instance == nil { 39 | instance = &HttpChallenge{ 40 | AuthInfo: map[string]*authInfo{}, 41 | } 42 | } 43 | return instance 44 | } 45 | 46 | type DNSChallenge struct { 47 | AuthInfo map[string]*authInfo 48 | } 49 | 50 | var dnsInstance *DNSChallenge 51 | 52 | func GetDnsChallengeInstance() *DNSChallenge { 53 | if dnsInstance == nil { 54 | dnsInstance = &DNSChallenge{ 55 | AuthInfo: map[string]*authInfo{}, 56 | } 57 | } 58 | return dnsInstance 59 | } 60 | 61 | func (h *DNSChallenge) Present(domain, token, keyAuth string) error { 62 | info := dns01.GetChallengeInfo(domain, keyAuth) 63 | log.Infof("Presenting challenge Info : %+v", info) 64 | h.AuthInfo[token] = &authInfo{ 65 | Domain: info.FQDN, 66 | Token: token, 67 | KeyAuth: info.Value, 68 | } 69 | log.Infof("SSL Log:%s %s %s", domain, token, keyAuth) 70 | return nil 71 | } 72 | 73 | func (h *DNSChallenge) CleanUp(domain, token, keyAuth string) error { 74 | delete(h.AuthInfo, token) 75 | return nil 76 | } 77 | 78 | func (h *DNSChallenge) Timeout() (timeout, interval time.Duration) { 79 | return 60 * time.Minute, 5 * time.Second 80 | } 81 | 82 | type DNSItem struct { 83 | Type string `json:"type"` 84 | Host string `json:"host"` 85 | Value string `json:"value"` 86 | TTL int `json:"ttl"` 87 | Tips string `json:"tips"` 88 | } 89 | 90 | func (h *DNSChallenge) GetDNSSettings(ctx *context.Context) []*DNSItem { 91 | ret := []*DNSItem{} 92 | for _, info := range h.AuthInfo { 93 | ret = append(ret, &DNSItem{ 94 | Type: "TXT", 95 | Host: info.Domain, 96 | Value: info.KeyAuth, 97 | TTL: 3600, 98 | }) 99 | } 100 | 101 | return ret 102 | } 103 | -------------------------------------------------------------------------------- /server/services/setup/ssl/dnsProvide.go: -------------------------------------------------------------------------------- 1 | package ssl 2 | 3 | //import ( 4 | // "github.com/go-acme/lego/v4/providers/dns" 5 | // "os" 6 | // "pmail/utils/errors" 7 | // "regexp" 8 | // "strings" 9 | //) 10 | // 11 | //func GetServerParamsList(serverName string) ([]string, error) { 12 | // var serverParams []string 13 | // 14 | // infos, err := os.ReadDir("./") 15 | // if err != nil { 16 | // return nil, errors.Wrap(err) 17 | // } 18 | // 19 | // upperServerName := strings.ToUpper(serverName) 20 | // 21 | // for _, info := range infos { 22 | // if strings.HasPrefix(info.Name(), upperServerName) { 23 | // serverParams = append(serverParams, info.Name()) 24 | // } 25 | // } 26 | // if len(serverParams) != 0 { 27 | // return serverParams, nil 28 | // } 29 | // 30 | // _, err = dns.NewDNSChallengeProviderByName(serverName) 31 | // if err == nil { 32 | // return nil, errors.New(serverName + " Not Support") 33 | // } 34 | // if strings.Contains(err.Error(), "unrecognized DNS provider") { 35 | // return nil, err 36 | // } 37 | // 38 | // re := regexp.MustCompile(`missing: (.+)`) 39 | // // namesilo: some credentials information are missing: NAMESILO_API_KEY 40 | // estr := err.Error() 41 | // name := re.FindStringSubmatch(estr) 42 | // 43 | // if len(name) == 2 { 44 | // names := strings.Split(name[1], ",") 45 | // 46 | // for _, s := range names { 47 | // serverParams = append(serverParams, s) 48 | // SetDomainServerParams(s, "empty") 49 | // } 50 | // 51 | // } 52 | // _, err = dns.NewDNSChallengeProviderByName(serverName) 53 | // 54 | // return serverParams, err 55 | //} 56 | // 57 | //func SetDomainServerParams(name, value string) { 58 | // key := name 59 | // err := os.WriteFile(key, []byte(value), 0644) 60 | // if err != nil { 61 | // panic(err) 62 | // } 63 | // err = os.Setenv(name+"_FILE", key) 64 | // if err != nil { 65 | // panic(err) 66 | // } 67 | //} 68 | -------------------------------------------------------------------------------- /server/services/setup/ssl/ssl_test.go: -------------------------------------------------------------------------------- 1 | package ssl 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Jinnrry/pmail/config" 6 | "testing" 7 | ) 8 | 9 | func TestCheckSSLCrtInfo(t *testing.T) { 10 | config.Init() 11 | 12 | got, got1, match, err := CheckSSLCrtInfo() 13 | 14 | fmt.Println(got, got1, match, err) 15 | } 16 | -------------------------------------------------------------------------------- /server/session/init.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/config" 5 | "github.com/Jinnrry/pmail/db" 6 | "github.com/alexedwards/scs/mysqlstore" 7 | "github.com/alexedwards/scs/postgresstore" 8 | "github.com/alexedwards/scs/sqlite3store" 9 | "github.com/alexedwards/scs/v2" 10 | 11 | "time" 12 | ) 13 | 14 | var Instance *scs.SessionManager 15 | 16 | func Init() { 17 | Instance = scs.New() 18 | Instance.Lifetime = 7 * 24 * time.Hour 19 | // 使用db存储session数据,目前为了架构简单, 20 | // 暂不引入redis存储,如果日后性能存在瓶颈,可以将session迁移到redis 21 | 22 | switch config.Instance.DbType { 23 | case config.DBTypeMySQL: 24 | Instance.Store = mysqlstore.New(db.Instance.DB().DB) 25 | case config.DBTypeSQLite: 26 | Instance.Store = sqlite3store.New(db.Instance.DB().DB) 27 | case config.DBTypePostgres: 28 | Instance.Store = postgresstore.New(db.Instance.DB().DB) 29 | default: 30 | panic("Unsupported database type: " + config.Instance.DbType) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /server/signal/signal.go: -------------------------------------------------------------------------------- 1 | package signal 2 | 3 | // InitChan 控制初始化流程结束 4 | var InitChan = make(chan bool) 5 | 6 | // RestartChan 控制程序重启 7 | var RestartChan = make(chan bool) 8 | 9 | // StopChan 控制程序结束 10 | var StopChan = make(chan bool) 11 | -------------------------------------------------------------------------------- /server/utils/address/address.go: -------------------------------------------------------------------------------- 1 | package address 2 | 3 | import "strings" 4 | 5 | // IsValidEmailAddress 检查是否是有效的邮箱地址 6 | func IsValidEmailAddress(str string) bool { 7 | ars := strings.Split(str, "@") 8 | if len(ars) != 2 { 9 | return false 10 | } 11 | return strings.Contains(ars[1], ".") 12 | } 13 | -------------------------------------------------------------------------------- /server/utils/address/address_test.go: -------------------------------------------------------------------------------- 1 | package address 2 | 3 | import "testing" 4 | 5 | func TestIsValidEmailAddress(t *testing.T) { 6 | type args struct { 7 | str string 8 | } 9 | tests := []struct { 10 | name string 11 | args args 12 | want bool 13 | }{ 14 | { 15 | "", 16 | args{"test@qq.com"}, 17 | true, 18 | }, 19 | { 20 | "", 21 | args{"1000@qq.com"}, 22 | true, 23 | }, 24 | { 25 | "", 26 | args{"1000@163.com"}, 27 | true, 28 | }, 29 | { 30 | "", 31 | args{"1000@1631com"}, 32 | false, 33 | }, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | if got := IsValidEmailAddress(tt.args.str); got != tt.want { 38 | t.Errorf("IsValidEmailAddress() = %v, want %v", got, tt.want) 39 | } 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /server/utils/array/array.go: -------------------------------------------------------------------------------- 1 | package array 2 | 3 | import ( 4 | "github.com/spf13/cast" 5 | "strings" 6 | ) 7 | 8 | func Join[T any](arg []T, str string) string { 9 | var ret strings.Builder 10 | for i, t := range arg { 11 | if i == 0 { 12 | ret.WriteString(cast.ToString(t)) 13 | } else { 14 | ret.WriteString(str) 15 | ret.WriteString(cast.ToString(t)) 16 | } 17 | } 18 | return ret.String() 19 | } 20 | 21 | // Unique 数组去重 22 | func Unique[T comparable](slice []T) []T { 23 | mp := map[T]bool{} 24 | for _, v := range slice { 25 | mp[v] = true 26 | } 27 | ret := []T{} 28 | for t, _ := range mp { 29 | ret = append(ret, t) 30 | } 31 | return ret 32 | } 33 | 34 | // Merge 求并集 35 | func Merge[T any](slice1, slice2 []T) []T { 36 | s1Len := len(slice1) 37 | 38 | slice3 := make([]T, s1Len+len(slice2)) 39 | for i, t := range slice1 { 40 | slice3[i] = t 41 | } 42 | 43 | for i, t := range slice2 { 44 | slice3[s1Len+i] = t 45 | } 46 | 47 | return slice3 48 | } 49 | 50 | // Intersect 求交集 51 | func Intersect[T comparable](slice1, slice2 []T) []T { 52 | m := make(map[T]bool) 53 | nn := make([]T, 0) 54 | for _, v := range slice1 { 55 | m[v] = true 56 | } 57 | 58 | for _, v := range slice2 { 59 | exist, _ := m[v] 60 | if exist { 61 | nn = append(nn, v) 62 | } 63 | } 64 | return nn 65 | } 66 | 67 | // Difference 求差集 slice1-并集 68 | func Difference[T comparable](slice1, slice2 []T) []T { 69 | m := make(map[T]bool) 70 | nn := make([]T, 0) 71 | inter := Intersect(slice1, slice2) 72 | for _, v := range inter { 73 | m[v] = true 74 | } 75 | 76 | for _, value := range slice1 { 77 | exist, _ := m[value] 78 | if !exist { 79 | nn = append(nn, value) 80 | } 81 | } 82 | return nn 83 | } 84 | 85 | // InArray 判断元素是否在数组中 86 | func InArray[T comparable](needle T, haystack []T) bool { 87 | for _, t := range haystack { 88 | if needle == t { 89 | return true 90 | } 91 | } 92 | 93 | return false 94 | } 95 | -------------------------------------------------------------------------------- /server/utils/async/async.go: -------------------------------------------------------------------------------- 1 | package async 2 | 3 | import ( 4 | "errors" 5 | "github.com/Jinnrry/pmail/utils/context" 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cast" 8 | "runtime/debug" 9 | "sync" 10 | ) 11 | 12 | type Callback func(params any) 13 | 14 | type Async struct { 15 | wg *sync.WaitGroup 16 | lastError error 17 | ctx *context.Context 18 | } 19 | 20 | func New(ctx *context.Context) *Async { 21 | return &Async{ 22 | ctx: ctx, 23 | } 24 | } 25 | 26 | func (as *Async) LastError() error { 27 | return as.lastError 28 | } 29 | 30 | func (as *Async) WaitProcess(callback Callback, params any) { 31 | if as.wg == nil { 32 | as.wg = &sync.WaitGroup{} 33 | } 34 | as.wg.Add(1) 35 | as.Process(func(params any) { 36 | defer as.wg.Done() 37 | callback(params) 38 | }, params) 39 | } 40 | 41 | func (as *Async) Process(callback Callback, params any) { 42 | go func() { 43 | defer func() { 44 | if err := recover(); err != nil { 45 | as.lastError = as.HandleErrRecover(err) 46 | } 47 | }() 48 | callback(params) 49 | }() 50 | } 51 | 52 | func (as *Async) Wait() { 53 | if as.wg == nil { 54 | return 55 | } 56 | as.wg.Wait() 57 | } 58 | 59 | // HandleErrRecover panic恢复处理 60 | func (as *Async) HandleErrRecover(err interface{}) (returnErr error) { 61 | switch err.(type) { 62 | case error: 63 | returnErr = err.(error) 64 | default: 65 | returnErr = errors.New(cast.ToString(err)) 66 | } 67 | 68 | log.WithContext(as.ctx).Errorf("goroutine panic:%s \n %s", err, string(debug.Stack())) 69 | 70 | return 71 | } 72 | -------------------------------------------------------------------------------- /server/utils/consts/consts.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | const ( 4 | TEST_DOMAIN = "test.domain" 5 | ) 6 | -------------------------------------------------------------------------------- /server/utils/context/context.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | const ( 8 | LogID = "LogID" 9 | ) 10 | 11 | type Context struct { 12 | context.Context `json:"-"` 13 | UserID int 14 | UserAccount string 15 | UserName string 16 | Values map[string]any 17 | Lang string 18 | IsAdmin bool 19 | } 20 | 21 | func (c *Context) SetValue(key string, value any) { 22 | if c.Values == nil { 23 | c.Values = map[string]any{} 24 | } 25 | c.Values[key] = value 26 | 27 | } 28 | 29 | func (c *Context) GetValue(key string) any { 30 | if c.Values == nil { 31 | return nil 32 | } 33 | return c.Values[key] 34 | } 35 | -------------------------------------------------------------------------------- /server/utils/errors/error.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | oe "errors" 5 | "fmt" 6 | "runtime" 7 | ) 8 | 9 | func New(text string) error { 10 | _, file, line, _ := runtime.Caller(1) 11 | return oe.New(fmt.Sprintf("%s at %s:%d", text, file, line)) 12 | } 13 | 14 | func Wrap(err error) error { 15 | _, file, line, _ := runtime.Caller(1) 16 | return fmt.Errorf("at %s:%d\n%w", file, line, err) 17 | } 18 | 19 | func WrapWithMsg(err error, msg string) error { 20 | _, file, line, _ := runtime.Caller(1) 21 | return fmt.Errorf("%s at %s:%d\n%w", msg, file, line, err) 22 | } 23 | 24 | func Unwrap(err error) error { 25 | return oe.Unwrap(err) 26 | } 27 | 28 | func Is(err, target error) bool { 29 | return oe.Is(err, target) 30 | } 31 | 32 | func As(err error, target any) bool { 33 | return oe.As(err, target) 34 | } 35 | -------------------------------------------------------------------------------- /server/utils/file/file.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import "os" 4 | 5 | func PathExist(_path string) bool { 6 | _, err := os.Stat(_path) 7 | if err != nil && os.IsNotExist(err) { 8 | return false 9 | } 10 | return true 11 | } 12 | -------------------------------------------------------------------------------- /server/utils/id/logid.go: -------------------------------------------------------------------------------- 1 | package id 2 | 3 | import ( 4 | "bytes" 5 | "encoding/hex" 6 | "fmt" 7 | "math/rand" 8 | "net" 9 | "os" 10 | "time" 11 | ) 12 | 13 | var ip_instance string 14 | 15 | func getLocalIP() string { 16 | if ip_instance != "" { 17 | return ip_instance 18 | } 19 | 20 | ip := "127.0.0.1" 21 | addrs, err := net.InterfaceAddrs() 22 | if err != nil { 23 | ip_instance = ip 24 | return ip 25 | } 26 | for _, a := range addrs { 27 | if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { 28 | if ipnet.IP.To4() != nil { 29 | ip = ipnet.IP.String() 30 | break 31 | } 32 | } 33 | } 34 | ip_instance = ip 35 | return ip 36 | } 37 | 38 | func GenLogID() string { 39 | r := rand.New(rand.NewSource(time.Now().UnixMicro())) 40 | 41 | ip := getLocalIP() 42 | 43 | now := time.Now() 44 | timestamp := uint32(now.Unix()) 45 | timeNano := now.UnixNano() 46 | pid := os.Getpid() 47 | b := bytes.Buffer{} 48 | 49 | b.WriteString(hex.EncodeToString(net.ParseIP(ip).To4())) 50 | b.WriteString(fmt.Sprintf("%x", timestamp&0xffffffff)) 51 | b.WriteString(fmt.Sprintf("%04x", timeNano&0xffff)) 52 | b.WriteString(fmt.Sprintf("%04x", pid&0xffff)) 53 | b.WriteString(fmt.Sprintf("%06x", r.Int31n(1<<24))) 54 | b.WriteString("b0") 55 | 56 | return b.String() 57 | } 58 | -------------------------------------------------------------------------------- /server/utils/ip/ip.go: -------------------------------------------------------------------------------- 1 | package ip 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | var ip string 10 | 11 | func GetIp() string { 12 | if ip != "" { 13 | return ip 14 | } 15 | 16 | resp, err := http.Get("http://ip-api.com/json/?lang=zh-CN ") 17 | if err != nil { 18 | return "[Your Server IP]" 19 | } 20 | defer resp.Body.Close() 21 | 22 | if resp.StatusCode == 200 { 23 | body, err := io.ReadAll(resp.Body) 24 | if err == nil { 25 | var queryRes map[string]string 26 | _ = json.Unmarshal(body, &queryRes) 27 | ip = queryRes["query"] 28 | return queryRes["query"] 29 | } 30 | } 31 | return "[Your Server IP]" 32 | } 33 | -------------------------------------------------------------------------------- /server/utils/password/encode.go: -------------------------------------------------------------------------------- 1 | package password 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | ) 7 | 8 | // Encode 对密码两次md5加盐 9 | func Encode(password string) string { 10 | encodePwd := Md5Encode(Md5Encode(password+"pmail") + "pmail2023") 11 | return encodePwd 12 | } 13 | 14 | func Md5Encode(str string) string { 15 | h := md5.New() 16 | h.Write([]byte(str)) 17 | return hex.EncodeToString(h.Sum(nil)) 18 | } 19 | -------------------------------------------------------------------------------- /server/utils/password/encode_test.go: -------------------------------------------------------------------------------- 1 | package password 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestEncode(t *testing.T) { 9 | fmt.Println(Encode("user2")) 10 | fmt.Println(Encode("user2New")) 11 | } 12 | -------------------------------------------------------------------------------- /server/utils/utf7/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 The Go-IMAP Authors 4 | Copyright (c) 2016 Proton Technologies AG 5 | Copyright (c) 2023 Simon Ser 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /server/utils/utf7/README.md: -------------------------------------------------------------------------------- 1 | COPY from https://github.com/emersion/go-imap/tree/v2/internal/utf7 -------------------------------------------------------------------------------- /server/utils/utf7/decoder.go: -------------------------------------------------------------------------------- 1 | package utf7 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "unicode/utf16" 7 | "unicode/utf8" 8 | ) 9 | 10 | // ErrInvalidUTF7 means that a decoder encountered invalid UTF-7. 11 | var ErrInvalidUTF7 = errors.New("utf7: invalid UTF-7") 12 | 13 | // Decode decodes a string encoded with modified UTF-7. 14 | // 15 | // Note, raw UTF-8 is accepted. 16 | func Decode(src string) (string, error) { 17 | if !utf8.ValidString(src) { 18 | return "", errors.New("invalid UTF-8") 19 | } 20 | 21 | var sb strings.Builder 22 | sb.Grow(len(src)) 23 | 24 | ascii := true 25 | for i := 0; i < len(src); i++ { 26 | ch := src[i] 27 | 28 | if ch < min || (ch > max && ch < utf8.RuneSelf) { 29 | // Illegal code point in ASCII mode. Note, UTF-8 codepoints are 30 | // always allowed. 31 | return "", ErrInvalidUTF7 32 | } 33 | 34 | if ch != '&' { 35 | sb.WriteByte(ch) 36 | ascii = true 37 | continue 38 | } 39 | 40 | // Find the end of the Base64 or "&-" segment 41 | start := i + 1 42 | for i++; i < len(src) && src[i] != '-'; i++ { 43 | if src[i] == '\r' || src[i] == '\n' { // base64 package ignores CR and LF 44 | return "", ErrInvalidUTF7 45 | } 46 | } 47 | 48 | if i == len(src) { // Implicit shift ("&...") 49 | return "", ErrInvalidUTF7 50 | } 51 | 52 | if i == start { // Escape sequence "&-" 53 | sb.WriteByte('&') 54 | ascii = true 55 | } else { // Control or non-ASCII code points in base64 56 | if !ascii { // Null shift ("&...-&...-") 57 | return "", ErrInvalidUTF7 58 | } 59 | 60 | b := decode([]byte(src[start:i])) 61 | if len(b) == 0 { // Bad encoding 62 | return "", ErrInvalidUTF7 63 | } 64 | sb.Write(b) 65 | 66 | ascii = false 67 | } 68 | } 69 | 70 | return sb.String(), nil 71 | } 72 | 73 | // Extracts UTF-16-BE bytes from base64 data and converts them to UTF-8. 74 | // A nil slice is returned if the encoding is invalid. 75 | func decode(b64 []byte) []byte { 76 | var b []byte 77 | 78 | // Allocate a single block of memory large enough to store the Base64 data 79 | // (if padding is required), UTF-16-BE bytes, and decoded UTF-8 bytes. 80 | // Since a 2-byte UTF-16 sequence may expand into a 3-byte UTF-8 sequence, 81 | // double the space allocation for UTF-8. 82 | if n := len(b64); b64[n-1] == '=' { 83 | return nil 84 | } else if n&3 == 0 { 85 | b = make([]byte, b64Enc.DecodedLen(n)*3) 86 | } else { 87 | n += 4 - n&3 88 | b = make([]byte, n+b64Enc.DecodedLen(n)*3) 89 | copy(b[copy(b, b64):n], []byte("==")) 90 | b64, b = b[:n], b[n:] 91 | } 92 | 93 | // Decode Base64 into the first 1/3rd of b 94 | n, err := b64Enc.Decode(b, b64) 95 | if err != nil || n&1 == 1 { 96 | return nil 97 | } 98 | 99 | // Decode UTF-16-BE into the remaining 2/3rds of b 100 | b, s := b[:n], b[n:] 101 | j := 0 102 | for i := 0; i < n; i += 2 { 103 | r := rune(b[i])<<8 | rune(b[i+1]) 104 | if utf16.IsSurrogate(r) { 105 | if i += 2; i == n { 106 | return nil 107 | } 108 | r2 := rune(b[i])<<8 | rune(b[i+1]) 109 | if r = utf16.DecodeRune(r, r2); r == utf8.RuneError { 110 | return nil 111 | } 112 | } else if min <= r && r <= max { 113 | return nil 114 | } 115 | j += utf8.EncodeRune(s[j:], r) 116 | } 117 | return s[:j] 118 | } 119 | -------------------------------------------------------------------------------- /server/utils/utf7/decoder_test.go: -------------------------------------------------------------------------------- 1 | package utf7_test 2 | 3 | import ( 4 | "github.com/Jinnrry/pmail/utils/utf7" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | var decode = []struct { 10 | in string 11 | out string 12 | ok bool 13 | }{ 14 | // Basics (the inverse test on encode checks other valid inputs) 15 | {"", "", true}, 16 | {"abc", "abc", true}, 17 | {"&-abc", "&abc", true}, 18 | {"abc&-", "abc&", true}, 19 | {"a&-b&-c", "a&b&c", true}, 20 | {"&ABk-", "\x19", true}, 21 | {"&AB8-", "\x1F", true}, 22 | {"ABk-", "ABk-", true}, 23 | {"&-,&-&AP8-&-", "&,&\u00FF&", true}, 24 | {"&-&-,&AP8-&-", "&&,\u00FF&", true}, 25 | {"abc &- &AP8A,wD,- &- xyz", "abc & \u00FF\u00FF\u00FF & xyz", true}, 26 | 27 | // Illegal code point in ASCII 28 | {"\x00", "", false}, 29 | {"\x1F", "", false}, 30 | {"abc\n", "", false}, 31 | {"abc\x7Fxyz", "", false}, 32 | 33 | // Invalid UTF-8 34 | {"\xc3\x28", "", false}, 35 | {"\xe2\x82\x28", "", false}, 36 | 37 | // Invalid Base64 alphabet 38 | {"&/+8-", "", false}, 39 | {"&*-", "", false}, 40 | {"&ZeVnLIqe -", "", false}, 41 | 42 | // CR and LF in Base64 43 | {"&ZeVnLIqe\r\n-", "", false}, 44 | {"&ZeVnLIqe\r\n\r\n-", "", false}, 45 | {"&ZeVn\r\n\r\nLIqe-", "", false}, 46 | 47 | // Padding not stripped 48 | {"&AAAAHw=-", "", false}, 49 | {"&AAAAHw==-", "", false}, 50 | {"&AAAAHwB,AIA=-", "", false}, 51 | {"&AAAAHwB,AIA==-", "", false}, 52 | 53 | // One byte short 54 | {"&2A-", "", false}, 55 | {"&2ADc-", "", false}, 56 | {"&AAAAHwB,A-", "", false}, 57 | {"&AAAAHwB,A=-", "", false}, 58 | {"&AAAAHwB,A==-", "", false}, 59 | {"&AAAAHwB,A===-", "", false}, 60 | {"&AAAAHwB,AI-", "", false}, 61 | {"&AAAAHwB,AI=-", "", false}, 62 | {"&AAAAHwB,AI==-", "", false}, 63 | 64 | // Implicit shift 65 | {"&", "", false}, 66 | {"&Jjo", "", false}, 67 | {"Jjo&", "", false}, 68 | {"&Jjo&", "", false}, 69 | {"&Jjo!", "", false}, 70 | {"&Jjo+", "", false}, 71 | {"abc&Jjo", "", false}, 72 | 73 | // Null shift 74 | {"&AGE-&Jjo-", "", false}, 75 | {"&U,BTFw-&ZeVnLIqe-", "", false}, 76 | 77 | // Long input with Base64 at the end 78 | {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa &2D3eCg- &2D3eCw- &2D3eDg-", 79 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \U0001f60a \U0001f60b \U0001f60e", true}, 80 | 81 | // Long input in Base64 between short ASCII 82 | {"00000000000000000000 &MEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEI- 00000000000000000000", 83 | "00000000000000000000 " + strings.Repeat("\U00003042", 37) + " 00000000000000000000", true}, 84 | 85 | // ASCII in Base64 86 | {"&AGE-", "", false}, // "a" 87 | {"&ACY-", "", false}, // "&" 88 | {"&AGgAZQBsAGwAbw-", "", false}, // "hello" 89 | {"&JjoAIQ-", "", false}, // "\u263a!" 90 | 91 | // Bad surrogate 92 | {"&2AA-", "", false}, // U+D800 93 | {"&2AD-", "", false}, // U+D800 94 | {"&3AA-", "", false}, // U+DC00 95 | {"&2AAAQQ-", "", false}, // U+D800 'A' 96 | {"&2AD,,w-", "", false}, // U+D800 U+FFFF 97 | {"&3ADYAA-", "", false}, // U+DC00 U+D800 98 | 99 | // Chinese 100 | {"&V4NXPpCuTvY-", "垃圾邮件", true}, 101 | {"&UXZO1mWHTvZZOQ-", "其他文件夹", true}, 102 | } 103 | 104 | func TestDecoder(t *testing.T) { 105 | for _, test := range decode { 106 | out, err := utf7.Decode(test.in) 107 | if out != test.out { 108 | t.Errorf("UTF7Decode(%+q) expected %+q; got %+q", test.in, test.out, out) 109 | } 110 | if test.ok { 111 | if err != nil { 112 | t.Errorf("UTF7Decode(%+q) unexpected error; %v", test.in, err) 113 | } 114 | } else if err == nil { 115 | t.Errorf("UTF7Decode(%+q) expected error", test.in) 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /server/utils/utf7/encoder.go: -------------------------------------------------------------------------------- 1 | package utf7 2 | 3 | import ( 4 | "strings" 5 | "unicode/utf16" 6 | "unicode/utf8" 7 | ) 8 | 9 | // Encode encodes a string with modified UTF-7. 10 | func Encode(src string) string { 11 | var sb strings.Builder 12 | sb.Grow(len(src)) 13 | 14 | for i := 0; i < len(src); { 15 | ch := src[i] 16 | 17 | if min <= ch && ch <= max { 18 | sb.WriteByte(ch) 19 | if ch == '&' { 20 | sb.WriteByte('-') 21 | } 22 | 23 | i++ 24 | } else { 25 | start := i 26 | 27 | // Find the next printable ASCII code point 28 | i++ 29 | for i < len(src) && (src[i] < min || src[i] > max) { 30 | i++ 31 | } 32 | 33 | sb.Write(encode([]byte(src[start:i]))) 34 | } 35 | } 36 | 37 | return sb.String() 38 | } 39 | 40 | // Converts string s from UTF-8 to UTF-16-BE, encodes the result as base64, 41 | // removes the padding, and adds UTF-7 shifts. 42 | func encode(s []byte) []byte { 43 | // len(s) is sufficient for UTF-8 to UTF-16 conversion if there are no 44 | // control code points (see table below). 45 | b := make([]byte, 0, len(s)+4) 46 | for len(s) > 0 { 47 | r, size := utf8.DecodeRune(s) 48 | if r > utf8.MaxRune { 49 | r, size = utf8.RuneError, 1 // Bug fix (issue 3785) 50 | } 51 | s = s[size:] 52 | if r1, r2 := utf16.EncodeRune(r); r1 != utf8.RuneError { 53 | b = append(b, byte(r1>>8), byte(r1)) 54 | r = r2 55 | } 56 | b = append(b, byte(r>>8), byte(r)) 57 | } 58 | 59 | // Encode as base64 60 | n := b64Enc.EncodedLen(len(b)) + 2 61 | b64 := make([]byte, n) 62 | b64Enc.Encode(b64[1:], b) 63 | 64 | // Strip padding 65 | n -= 2 - (len(b)+2)%3 66 | b64 = b64[:n] 67 | 68 | // Add UTF-7 shifts 69 | b64[0] = '&' 70 | b64[n-1] = '-' 71 | return b64 72 | } 73 | 74 | // Escape passes through raw UTF-8 as-is and escapes the special UTF-7 marker 75 | // (the ampersand character). 76 | func Escape(src string) string { 77 | var sb strings.Builder 78 | sb.Grow(len(src)) 79 | 80 | for _, ch := range src { 81 | sb.WriteRune(ch) 82 | if ch == '&' { 83 | sb.WriteByte('-') 84 | } 85 | } 86 | 87 | return sb.String() 88 | } 89 | -------------------------------------------------------------------------------- /server/utils/utf7/utf7.go: -------------------------------------------------------------------------------- 1 | // Package utf7 implements modified UTF-7 encoding defined in RFC 3501 section 5.1.3 2 | package utf7 3 | 4 | import ( 5 | "encoding/base64" 6 | ) 7 | 8 | const ( 9 | min = 0x20 // Minimum self-representing UTF-7 value 10 | max = 0x7E // Maximum self-representing UTF-7 value 11 | ) 12 | 13 | var b64Enc = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,") 14 | -------------------------------------------------------------------------------- /server/utils/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | func LT(version1, version2 string) bool { 4 | if version2 == "test" { 5 | return true 6 | } 7 | 8 | return version1 < version2 9 | } 10 | 11 | func GT(version1, version2 string) bool { 12 | if version2 == "test" { 13 | return false 14 | } 15 | return version1 > version2 16 | } 17 | -------------------------------------------------------------------------------- /server/utils/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import "testing" 4 | 5 | func TestLT(t *testing.T) { 6 | type args struct { 7 | version1 string 8 | version2 string 9 | } 10 | tests := []struct { 11 | name string 12 | args args 13 | want bool 14 | }{ 15 | { 16 | name: "test1", 17 | args: args{ 18 | version1: "1.0.0", 19 | version2: "1.0.0", 20 | }, 21 | want: false, 22 | }, 23 | { 24 | name: "test1", 25 | args: args{ 26 | version1: "2.0.0", 27 | version2: "1.0.0", 28 | }, 29 | want: false, 30 | }, 31 | { 32 | name: "test1", 33 | args: args{ 34 | version1: "1.0.0", 35 | version2: "2.0.0", 36 | }, 37 | want: true, 38 | }, 39 | { 40 | name: "test1", 41 | args: args{ 42 | version1: "", 43 | version2: "1.0.0", 44 | }, 45 | want: true, 46 | }, 47 | } 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | if got := LT(tt.args.version1, tt.args.version2); got != tt.want { 51 | t.Errorf("LT() = %v, want %v", got, tt.want) 52 | } 53 | }) 54 | } 55 | } 56 | --------------------------------------------------------------------------------