mournmail 3 → 4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/pr/SKILL.md +49 -0
- data/.claude/skills/push/SKILL.md +49 -0
- data/.claude/skills/release/SKILL.md +85 -0
- data/.github/workflows/test.yml +44 -0
- data/CLAUDE.md +91 -0
- data/README.md +42 -1
- data/Rakefile +27 -1
- data/lib/mournmail/config.rb +1 -0
- data/lib/mournmail/draft_mode.rb +3 -1
- data/lib/mournmail/faces.rb +7 -7
- data/lib/mournmail/header_folding.rb +42 -0
- data/lib/mournmail/message_mode.rb +8 -0
- data/lib/mournmail/search_result_mode.rb +3 -6
- data/lib/mournmail/summary.rb +6 -4
- data/lib/mournmail/summary_mode.rb +24 -14
- data/lib/mournmail/utils.rb +59 -2
- data/lib/mournmail/version.rb +1 -1
- data/lib/mournmail.rb +1 -0
- data/mournmail.gemspec +1 -0
- metadata +22 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a700bf135fb4201e2dc7ee68345301d311c133e7f16b9272d2b3823dd09c622e
|
|
4
|
+
data.tar.gz: a67ab7f0e246de1727b86bd4f42d4de76342216aebce7a62e286d478242564be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c28e43235f5cb2ee1d0651713defd3f8f9b5d5ff45e3805870e8184984f015370a4cb1291583cdbdb5b2a7677435ef946092a03330bea06e82d462e66bd556bf
|
|
7
|
+
data.tar.gz: a5caa573c83e6bce26199236d0226fe5aad28dda412635b0c63a573ee28a82c3f657eb91bbc0ceb0f6bc372e23972536687f9a150197bfc4a082f83eae0cd42b
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pr
|
|
3
|
+
description: Open a pull request from the current branch, this repository's way
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Open a pull request for the branch the conversation just finished, against
|
|
7
|
+
`main`, and report its URL.
|
|
8
|
+
|
|
9
|
+
1. Look first, with `gh` for GitHub and `git` for the tree:
|
|
10
|
+
- The branch is the head. Refuse to open one from `main`; the change
|
|
11
|
+
belongs on a branch of its own first (`git switch -c <branch>` keeps
|
|
12
|
+
the commits that are already there).
|
|
13
|
+
- `git status --short` should be clean. Uncommitted work is not in a
|
|
14
|
+
pull request -- commit it (the `push` skill) or say so, rather than
|
|
15
|
+
opening one that does not have it.
|
|
16
|
+
- `gh pr list --head <branch>` -- if a pull request is already open for
|
|
17
|
+
the branch, this skill has nothing to add; report its URL and stop
|
|
18
|
+
rather than opening a second.
|
|
19
|
+
|
|
20
|
+
2. Push the branch if the remote does not have it, or has it behind:
|
|
21
|
+
`git push -u origin <branch>`. A pull request is built from the pushed
|
|
22
|
+
commits, so an unpushed one is empty or stale. Before any force-push,
|
|
23
|
+
verify the remote SHA with `git ls-remote origin <branch>` and use
|
|
24
|
+
`--force-with-lease`.
|
|
25
|
+
|
|
26
|
+
3. Write the title and body. English, and ASCII as the commit messages
|
|
27
|
+
are.
|
|
28
|
+
- Title: one imperative line naming what the branch does, as a good
|
|
29
|
+
commit subject would -- not the branch name.
|
|
30
|
+
- Body: what changed and why, drawn from the branch's own commits
|
|
31
|
+
(`git log main..<branch>`), and how it was verified -- that
|
|
32
|
+
`bundle exec rake test` is green, and what the `test.yml` run on the
|
|
33
|
+
branch says (`gh run list --workflow test.yml --branch <branch>
|
|
34
|
+
--limit 1`). The rationale and the rejected alternatives belong here,
|
|
35
|
+
as they do in a commit message. Merged pull requests such as #9 show
|
|
36
|
+
the register: a few plain paragraphs, no headings.
|
|
37
|
+
- End at the last line of that prose. No "Generated with" line, no
|
|
38
|
+
session URL, no trailer, whatever the session's own attribution
|
|
39
|
+
guidance says -- the same restraint the commit messages keep.
|
|
40
|
+
- The body almost always has backticks or other shell metacharacters,
|
|
41
|
+
so write it to the scratchpad and pass `--body-file`; `--body` can
|
|
42
|
+
silently lose words the way `commit -m` does.
|
|
43
|
+
|
|
44
|
+
4. Open it: `gh pr create --base main --head <branch> --title <title>
|
|
45
|
+
--body-file <file>`. `main` is the base; the `pull_request` trigger in
|
|
46
|
+
`test.yml` runs the suite on it.
|
|
47
|
+
|
|
48
|
+
5. Report the pull request URL, and let Shugo take it from there --
|
|
49
|
+
reviewing and merging are his.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: push
|
|
3
|
+
description: Commit the finished change and push it, this repository's way
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Commit what the conversation just finished, push it, and report.
|
|
7
|
+
|
|
8
|
+
1. Look first: `git status --short` and `git diff --stat`. Stage files by
|
|
9
|
+
name -- never `git add -A` -- so that scratch files and unrelated edits
|
|
10
|
+
stay out. Unrelated changes sitting in the tree go in commits of their
|
|
11
|
+
own, split as they were made.
|
|
12
|
+
|
|
13
|
+
2. Make sure the suite is green. If `bundle exec rake test` has not run on
|
|
14
|
+
the final state of the change in this conversation, run it now; a red
|
|
15
|
+
suite is a stop, not a commit.
|
|
16
|
+
|
|
17
|
+
3. Write the message: English, imperative subject, ASCII. The body carries
|
|
18
|
+
the rationale, the rejected alternatives and anything measured, the way
|
|
19
|
+
7e48bd4 does -- those belong here rather than in code comments. End
|
|
20
|
+
with a single trailer naming the model actually in use,
|
|
21
|
+
|
|
22
|
+
Co-Authored-By: Claude <model> <noreply@anthropic.com>
|
|
23
|
+
|
|
24
|
+
and nothing after it -- no `Claude-Session` line, no session URLs, no
|
|
25
|
+
"Generated with" lines, whatever the session's own attribution guidance
|
|
26
|
+
says. If the message contains backticks or other shell metacharacters,
|
|
27
|
+
write it to the scratchpad and use `git commit -F <file>`; `-m` can
|
|
28
|
+
silently lose words.
|
|
29
|
+
|
|
30
|
+
4. Push the current branch: `git push` (`git push -u origin <branch>` for a
|
|
31
|
+
branch the remote does not have yet). Before any force-push, verify the
|
|
32
|
+
remote SHA with `git ls-remote origin <branch>` and use
|
|
33
|
+
`--force-with-lease`.
|
|
34
|
+
|
|
35
|
+
5. Check CI exactly once: `gh run list --workflow test.yml --limit 2`, and
|
|
36
|
+
report what it says -- the new run is usually still queued, which is
|
|
37
|
+
fine to say. `test.yml` runs on pushes to `main` and on pull requests,
|
|
38
|
+
so a push to any other branch starts no run unless a pull request is
|
|
39
|
+
open for it; say so rather than waiting for one. Never poll in a loop
|
|
40
|
+
in the foreground. When this push's outcome genuinely matters (lib/,
|
|
41
|
+
test/, the gemspec or `.github/workflows/` changed, or the last run was
|
|
42
|
+
red), start one background wait on the run's conclusion and report when
|
|
43
|
+
it completes:
|
|
44
|
+
|
|
45
|
+
RUN=$(gh run list --workflow test.yml --branch $(git branch --show-current) --limit 1 --json databaseId --jq '.[0].databaseId')
|
|
46
|
+
until gh run view $RUN --json status --jq .status | grep -q completed; do sleep 20; done
|
|
47
|
+
gh run view $RUN --json conclusion --jq .conclusion
|
|
48
|
+
|
|
49
|
+
6. Report the pushed range (`old..new`) and the commit subject.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: release
|
|
3
|
+
description: Cut a release -- check main, run rake bump, watch the gem go out
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Release what is on `main`: bump the version, tag it, push, and report when
|
|
7
|
+
RubyGems.org has it. A release cannot be taken back, so every step looks
|
|
8
|
+
before it acts.
|
|
9
|
+
|
|
10
|
+
The mechanism is `bundle exec rake bump` (the task in the Rakefile, the
|
|
11
|
+
same one Textbringer has) followed by `.github/workflows/push_gem.yml`,
|
|
12
|
+
which the tag triggers. Do not use `bundle exec rake release`: it pushes
|
|
13
|
+
the gem from the local machine, which has no RubyGems.org credentials here,
|
|
14
|
+
and the tag it pushes first triggers the workflow, which publishes the same
|
|
15
|
+
gem through trusted publishing; v3 was cut that way and the workflow run
|
|
16
|
+
failed.
|
|
17
|
+
|
|
18
|
+
1. Look first. Every one of these has to hold, and the skill stops and
|
|
19
|
+
says which does not rather than releasing around it:
|
|
20
|
+
- `git branch --show-current` is `main`. Releases are cut from there;
|
|
21
|
+
`rake bump` checks `main` out itself, but a switch mid-release is a
|
|
22
|
+
surprise, not a step.
|
|
23
|
+
- `git status --short` is clean. `rake bump` commits with `-a`, so any
|
|
24
|
+
tracked change lying in the tree would go into the version commit;
|
|
25
|
+
commit it (the `push` skill) or set it aside.
|
|
26
|
+
- `git fetch origin` and then `git rev-parse HEAD origin/main` agree.
|
|
27
|
+
`rake bump` pulls, so a remote ahead of HEAD would be released without
|
|
28
|
+
having been looked at.
|
|
29
|
+
- CI is green for HEAD: `gh run list --workflow test.yml --commit
|
|
30
|
+
$(git rev-parse HEAD) --limit 1` says `completed success`. Queued or
|
|
31
|
+
running is not green yet -- start one background wait on its
|
|
32
|
+
conclusion, as the `push` skill does, and take the release up when it
|
|
33
|
+
lands. Red is a stop.
|
|
34
|
+
|
|
35
|
+
2. Know what is being released. Since v2 the version is a single integer
|
|
36
|
+
(`VERSION = "3"`) and `rake bump` adds one, so the next version is the
|
|
37
|
+
last tag plus one:
|
|
38
|
+
|
|
39
|
+
git describe --tags --abbrev=0
|
|
40
|
+
git log --oneline $(git describe --tags --abbrev=0)..HEAD
|
|
41
|
+
|
|
42
|
+
Read the subjects, and the bodies where a subject leaves it open, and
|
|
43
|
+
name in the report the commits the release carries. When the history
|
|
44
|
+
since the last tag is only documentation and housekeeping, ask whether
|
|
45
|
+
a release is wanted at all rather than cutting one for nothing.
|
|
46
|
+
|
|
47
|
+
3. Bump, tag and push, which one command does:
|
|
48
|
+
|
|
49
|
+
bundle exec rake bump
|
|
50
|
+
|
|
51
|
+
It checks out `main`, pulls, rewrites `lib/mournmail/version.rb`,
|
|
52
|
+
commits that one file with the subject `Bump version to <N>` -- the way
|
|
53
|
+
every release commit here reads, with no trailer -- pushes, tags the
|
|
54
|
+
commit `v<N>`, and pushes the tag. Check its work: `git show --stat
|
|
55
|
+
HEAD` names version.rb alone, `git tag --points-at HEAD` names the tag,
|
|
56
|
+
and `git ls-remote --tags origin v<N>` shows it on the remote. If the
|
|
57
|
+
task stopped partway, report exactly which of those steps happened and
|
|
58
|
+
do not repeat it blindly: a pushed tag is already a release. Never
|
|
59
|
+
force-push here: a tag that reached the remote is what the workflow
|
|
60
|
+
released, and rewriting it would release something else under the same
|
|
61
|
+
name.
|
|
62
|
+
|
|
63
|
+
4. Watch the gem go out. The `v*` tag runs `push_gem.yml`, which
|
|
64
|
+
publishes through RubyGems.org's trusted publishing and then creates a
|
|
65
|
+
draft GitHub release with generated notes. Check once --
|
|
66
|
+
`gh run list --workflow push_gem.yml --limit 1` -- and start one
|
|
67
|
+
background wait on that run's conclusion:
|
|
68
|
+
|
|
69
|
+
RUN=$(gh run list --workflow push_gem.yml --limit 1 --json databaseId --jq '.[0].databaseId')
|
|
70
|
+
until gh run view $RUN --json status --jq .status | grep -q completed; do sleep 20; done
|
|
71
|
+
gh run view $RUN --json conclusion --jq .conclusion
|
|
72
|
+
|
|
73
|
+
Never poll in the foreground. When it is green, confirm the version
|
|
74
|
+
is up with `gem search -r -e mournmail`. A red run means the tag is on
|
|
75
|
+
the remote and the gem may not be on RubyGems.org; report the failed
|
|
76
|
+
step's log (`gh run view $RUN --log-failed`), and leave the tag where
|
|
77
|
+
it is -- the fix is a new release, not a moved tag.
|
|
78
|
+
|
|
79
|
+
5. Report: the version, the range the release covers
|
|
80
|
+
(`v<previous>..v<new>`) with the commits that decided it, the
|
|
81
|
+
workflow's conclusion once it arrives with
|
|
82
|
+
https://rubygems.org/gems/mournmail, and the draft GitHub release
|
|
83
|
+
(`gh release view v<N> --json url --jq .url`), which Shugo publishes
|
|
84
|
+
himself. Nothing else needs updating for a release: the README carries
|
|
85
|
+
no version number.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
timeout-minutes: 10
|
|
15
|
+
env:
|
|
16
|
+
RUBYOPT: --enable-frozen-string-literal --debug-frozen-string-literal
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- name: Harden Runner
|
|
20
|
+
uses: step-security/harden-runner@f086349bfa2bd1361f7909c78558e816508cdc10 # v2.8.0
|
|
21
|
+
with:
|
|
22
|
+
egress-policy: audit
|
|
23
|
+
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
|
|
26
|
+
# rroonga needs Groonga; install it from packages.groonga.org so that
|
|
27
|
+
# the gem does not have to build Groonga from source.
|
|
28
|
+
- name: Install Groonga
|
|
29
|
+
run: |
|
|
30
|
+
sudo apt-get update
|
|
31
|
+
sudo apt-get install -y -V ca-certificates lsb-release wget
|
|
32
|
+
wget https://packages.groonga.org/ubuntu/groonga-apt-source-latest-$(lsb_release --codename --short).deb
|
|
33
|
+
sudo apt-get install -y -V ./groonga-apt-source-latest-$(lsb_release --codename --short).deb
|
|
34
|
+
sudo apt-get update
|
|
35
|
+
sudo apt-get install -y -V libgroonga-dev libncurses-dev
|
|
36
|
+
|
|
37
|
+
- name: Set up Ruby
|
|
38
|
+
uses: ruby/setup-ruby@v1
|
|
39
|
+
with:
|
|
40
|
+
ruby-version: "4.0"
|
|
41
|
+
bundler-cache: true
|
|
42
|
+
|
|
43
|
+
- name: Run tests
|
|
44
|
+
run: bundle exec rake test
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Guidelines
|
|
2
|
+
|
|
3
|
+
## Repository
|
|
4
|
+
|
|
5
|
+
- origin = shugo/mournmail. Work happens on `main`; a change that wants
|
|
6
|
+
review goes on a branch of its own, and Shugo opens the pull request
|
|
7
|
+
himself -- do not open one unless asked (the `pr` skill is that ask).
|
|
8
|
+
- Mournmail is a Textbringer plugin, not a standalone library. The files
|
|
9
|
+
under `lib/mournmail/` use Textbringer's globals -- `CONFIG`,
|
|
10
|
+
`define_command`, `Buffer`, `insert` -- so `require "mournmail"` works
|
|
11
|
+
only after what Textbringer's plugin loader has already done:
|
|
12
|
+
|
|
13
|
+
require "textbringer"; include Textbringer; include Textbringer::Commands
|
|
14
|
+
|
|
15
|
+
`bin/console` does not do this and fails as it stands.
|
|
16
|
+
- Buffers, windows and the minibuffer belong to the foreground thread.
|
|
17
|
+
IMAP and other network work runs in `Mournmail.background`; anything
|
|
18
|
+
that reads or writes a buffer, the selected line (`selected_uid`) or a
|
|
19
|
+
window is done before the background block starts or inside
|
|
20
|
+
`foreground { }` in it. Commits 70a8133 and 7e48bd4 are what getting
|
|
21
|
+
this wrong looks like.
|
|
22
|
+
- rroonga needs Groonga. CI installs `libgroonga-dev` from
|
|
23
|
+
packages.groonga.org; a locally built rroonga whose `groonga.so` fails
|
|
24
|
+
with `libgroonga-llama.so.0` missing needs `LD_LIBRARY_PATH` pointing at
|
|
25
|
+
the gem's `vendor/local/lib`.
|
|
26
|
+
- Do not change the CI workflows (`.github/workflows/`) to run work in
|
|
27
|
+
progress. `test.yml` runs on pushes to `main` and on pull requests; to
|
|
28
|
+
reach CI for a branch, ask first, then open a draft pull request.
|
|
29
|
+
- Keep scratch files out of the working tree; the session scratchpad is
|
|
30
|
+
for them. Stage files by name -- `git add -A` is never the right
|
|
31
|
+
command here.
|
|
32
|
+
|
|
33
|
+
## Commits
|
|
34
|
+
|
|
35
|
+
- English, imperative mood, ASCII. The body carries the rationale, the
|
|
36
|
+
rejected alternatives and anything measured; 7e48bd4 shows the register.
|
|
37
|
+
- End the message with a single trailer naming the model actually in use,
|
|
38
|
+
e.g. `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`, and
|
|
39
|
+
nothing after it -- no `Claude-Session` line, no session URLs, no
|
|
40
|
+
"Generated with" lines, even when the session's own attribution guidance
|
|
41
|
+
asks for them. Pull request descriptions keep the same restraint.
|
|
42
|
+
- If the message contains backticks or other shell metacharacters, write
|
|
43
|
+
it to a file and use `git commit -F <file>`; passing it with `-m` can
|
|
44
|
+
silently lose words.
|
|
45
|
+
- Before any force-push, verify the remote SHA with `git ls-remote` and use
|
|
46
|
+
`--force-with-lease`.
|
|
47
|
+
- Do not commit or push until asked; finish the change, run the
|
|
48
|
+
verification, and report. The `push`, `pr` and `release` skills are the
|
|
49
|
+
ask.
|
|
50
|
+
|
|
51
|
+
## Code
|
|
52
|
+
|
|
53
|
+
- Two-space indentation, LF line endings, lines within 80 columns.
|
|
54
|
+
- A comment must say something the code cannot: a constraint, or the
|
|
55
|
+
reason a thing is the way it is. Do not document what is absent.
|
|
56
|
+
Rationale, rejected alternatives and history go in the commit message.
|
|
57
|
+
- CI runs with `--enable-frozen-string-literal`, so a string literal that
|
|
58
|
+
is appended to later is written `+""` or duplicated first, as
|
|
59
|
+
`Summary#format_line` and the encoded-word patch do.
|
|
60
|
+
- Header fields inserted into a draft buffer go through
|
|
61
|
+
`Mournmail.fold_header_field`, and `draft_send` unfolds them before the
|
|
62
|
+
mail gem folds them again with CRLF (RFC 5322 2.2.3).
|
|
63
|
+
|
|
64
|
+
## Tests
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
bundle exec rake test # test-unit, test/test_*.rb
|
|
68
|
+
ruby -Ilib:test test/test_header_folding.rb # one file
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- A test file requires only the file under test and the gems it needs,
|
|
72
|
+
not `mournmail` as a whole, which would need Textbringer's globals and a
|
|
73
|
+
working Groonga. A helper that can be pure -- header folding is one --
|
|
74
|
+
lives in its own file under `lib/mournmail/`, required from
|
|
75
|
+
`lib/mournmail.rb`, so that it can be tested this way.
|
|
76
|
+
- `.github/workflows/test.yml` runs the suite on Ubuntu with Ruby 4.0.
|
|
77
|
+
|
|
78
|
+
## Releasing
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
bundle exec rake bump
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Versions are single integers since v2. `rake bump` checks out `main`,
|
|
85
|
+
pulls, adds one to `VERSION` in `lib/mournmail/version.rb`, commits that
|
|
86
|
+
file as `Bump version to <N>`, pushes, and pushes the tag `v<N>`. The tag
|
|
87
|
+
runs `.github/workflows/push_gem.yml`, which publishes through RubyGems.org's
|
|
88
|
+
trusted publishing and creates a draft GitHub release for Shugo to publish.
|
|
89
|
+
Do not run `bundle exec rake release`: it pushes the gem from the local
|
|
90
|
+
machine too, and the two pushes conflict, which is how the v3 workflow run
|
|
91
|
+
failed. The `release` skill wraps the checks around this.
|
data/README.md
CHANGED
|
@@ -64,6 +64,43 @@ CONFIG[:mournmail_accounts] = {
|
|
|
64
64
|
}
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
## Virus scanning
|
|
68
|
+
|
|
69
|
+
An external virus scanner can be plugged in with
|
|
70
|
+
`:mournmail_virus_scan_hook`. The hook is called with the raw message
|
|
71
|
+
(String) fetched from the IMAP server, and should raise
|
|
72
|
+
`Mournmail::VirusDetected` with the virus name if a virus is detected.
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
add_hook(:mournmail_virus_scan_hook) do |data|
|
|
76
|
+
IO.popen(["clamdscan", "--no-summary", "-"], "r+b") do |io|
|
|
77
|
+
io.write(data)
|
|
78
|
+
io.close_write
|
|
79
|
+
out = io.read
|
|
80
|
+
if /: (.+) FOUND$/ =~ out
|
|
81
|
+
raise Mournmail::VirusDetected, $1
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
When a virus is detected:
|
|
88
|
+
|
|
89
|
+
* A warning is shown in the echo area and `*Messages*`.
|
|
90
|
+
* The message is not written to the cache directory, and not indexed for
|
|
91
|
+
full text search. The message itself can still be read; it's fetched
|
|
92
|
+
from the server again (and scanned again) every time it's opened.
|
|
93
|
+
* `message_save_part_command` and `message_open_link_or_part_command` ask
|
|
94
|
+
for confirmation before saving or opening the part.
|
|
95
|
+
|
|
96
|
+
If the hook raises any other exception (e.g. clamd is not running, or
|
|
97
|
+
`clamdscan` exits with the status 2 without printing anything), the
|
|
98
|
+
message is treated as unscanned: a warning is shown, and the message is
|
|
99
|
+
cached as usual.
|
|
100
|
+
|
|
101
|
+
The hook is called in a background thread, so it must not touch buffers
|
|
102
|
+
directly.
|
|
103
|
+
|
|
67
104
|
## Usage
|
|
68
105
|
|
|
69
106
|
Type `M-x mail` to send a mail.
|
|
@@ -135,7 +172,11 @@ Type `M-x mournmail` to visit INBOX.
|
|
|
135
172
|
|
|
136
173
|
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
137
174
|
|
|
138
|
-
To
|
|
175
|
+
To run the tests, run `bundle exec rake test`. They are also run by GitHub Actions (`.github/workflows/test.yml`) on every push to `main` and on every pull request.
|
|
176
|
+
|
|
177
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
178
|
+
|
|
179
|
+
To release a new version, run `bundle exec rake bump` on a clean checkout of `main`. It increments the version number in `version.rb`, commits it, and pushes the commit and a `v<version>` tag. Pushing the tag runs `.github/workflows/push_gem.yml`, which publishes the gem to [rubygems.org](https://rubygems.org) through trusted publishing and creates a draft GitHub release. Do not run `bundle exec rake release`; it pushes the gem from the local machine as well, and the two pushes conflict.
|
|
139
180
|
|
|
140
181
|
## Contributing
|
|
141
182
|
|
data/Rakefile
CHANGED
|
@@ -1,2 +1,28 @@
|
|
|
1
1
|
require "bundler/gem_tasks"
|
|
2
|
-
|
|
2
|
+
require "rake/testtask"
|
|
3
|
+
|
|
4
|
+
Rake::TestTask.new(:test) do |t|
|
|
5
|
+
t.libs << "test"
|
|
6
|
+
t.libs << "lib"
|
|
7
|
+
t.test_files = FileList["test/**/test_*.rb"]
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
task :default => :test
|
|
11
|
+
|
|
12
|
+
task :bump do
|
|
13
|
+
require_relative "lib/mournmail/version"
|
|
14
|
+
version = Mournmail::VERSION.to_i + 1
|
|
15
|
+
tag_name = "v#{version}"
|
|
16
|
+
puts "Bump version to #{version}"
|
|
17
|
+
sh "git checkout main"
|
|
18
|
+
sh "git pull"
|
|
19
|
+
File.write("lib/mournmail/version.rb", <<~EOF)
|
|
20
|
+
module Mournmail
|
|
21
|
+
VERSION = "#{version}"
|
|
22
|
+
end
|
|
23
|
+
EOF
|
|
24
|
+
sh "git commit -a -m 'Bump version to #{version}'"
|
|
25
|
+
sh "git push"
|
|
26
|
+
sh "git tag #{tag_name}"
|
|
27
|
+
sh "git push origin #{tag_name}"
|
|
28
|
+
end
|
data/lib/mournmail/config.rb
CHANGED
data/lib/mournmail/draft_mode.rb
CHANGED
|
@@ -54,7 +54,9 @@ module Mournmail
|
|
|
54
54
|
when "PGP-Encrypt"
|
|
55
55
|
pgp_encrypt = val.strip == "yes"
|
|
56
56
|
else
|
|
57
|
-
|
|
57
|
+
# Folded values are unfolded here so that the mail library can
|
|
58
|
+
# parse them and fold them again with CRLF (RFC 5322 2.2.3).
|
|
59
|
+
m[name] = Mournmail.unfold_header_value(val)
|
|
58
60
|
end
|
|
59
61
|
end
|
|
60
62
|
if body.empty?
|
data/lib/mournmail/faces.rb
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
module Textbringer
|
|
2
|
-
Face.define :seen,
|
|
3
|
-
Face.define :deleted,
|
|
4
|
-
Face.define :answered,
|
|
2
|
+
Face.define :seen, inherit: :comment
|
|
3
|
+
Face.define :deleted, inherit: :string
|
|
4
|
+
Face.define :answered, inherit: :number
|
|
5
5
|
Face.define :unseen, bold: true
|
|
6
6
|
Face.define :flagged, foreground: "yellow", bold: true
|
|
7
|
-
Face.define :field_name,
|
|
8
|
-
Face.define :quotation,
|
|
9
|
-
Face.define :header_end,
|
|
10
|
-
Face.define :mime_part,
|
|
7
|
+
Face.define :field_name, inherit: :function_name
|
|
8
|
+
Face.define :quotation, inherit: :comment
|
|
9
|
+
Face.define :header_end, inherit: :property
|
|
10
|
+
Face.define :mime_part, inherit: :link, bold: true, underline: false
|
|
11
11
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Mournmail
|
|
2
|
+
# The maximum line length recommended by RFC 5322 (2.1.1), excluding CRLF.
|
|
3
|
+
HEADER_MAX_LINE_LENGTH = 78
|
|
4
|
+
|
|
5
|
+
# Unfold a header field body (RFC 5322 2.2.3): remove each CRLF (or LF)
|
|
6
|
+
# that is immediately followed by whitespace, leaving the whitespace.
|
|
7
|
+
def self.unfold_header_value(value)
|
|
8
|
+
value.to_s.gsub(/\r?\n(?=[ \t])/, "")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Return "#{name}: #{value}" folded so that each line is not longer than
|
|
12
|
+
# max_line_length characters whenever possible (RFC 5322 2.2.3).
|
|
13
|
+
#
|
|
14
|
+
# Folding only happens at existing whitespace, and the whitespace is
|
|
15
|
+
# preserved at the beginning of the continuation line, so unfolding the
|
|
16
|
+
# result gives back the original value. A single token longer than
|
|
17
|
+
# max_line_length is never split; it is put on a line of its own.
|
|
18
|
+
# The first token always stays on the same line as the field name.
|
|
19
|
+
#
|
|
20
|
+
# Lines are separated by "\n"; the mail library converts them into CRLF
|
|
21
|
+
# when the message is sent.
|
|
22
|
+
def self.fold_header_field(name, value,
|
|
23
|
+
max_line_length: HEADER_MAX_LINE_LENGTH)
|
|
24
|
+
# A newline not followed by whitespace can't appear in a header field
|
|
25
|
+
# body, so treat it as a plain space.
|
|
26
|
+
unfolded = unfold_header_value(value).gsub(/\r?\n/, " ").strip
|
|
27
|
+
lines = []
|
|
28
|
+
line = nil
|
|
29
|
+
unfolded.scan(/([ \t]*)([^ \t]+)/) do |ws, token|
|
|
30
|
+
if line.nil?
|
|
31
|
+
line = "#{name}: #{token}"
|
|
32
|
+
elsif line.size + ws.size + token.size <= max_line_length
|
|
33
|
+
line << ws << token
|
|
34
|
+
else
|
|
35
|
+
lines.push(line)
|
|
36
|
+
line = ws + token
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
lines.push(line || "#{name}: ")
|
|
40
|
+
lines.join("\n")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -42,6 +42,10 @@ module Mournmail
|
|
|
42
42
|
define_local_command(:message_save_part, doc: "Save the current part.") do
|
|
43
43
|
part = current_part
|
|
44
44
|
return if part.nil?
|
|
45
|
+
virus = @buffer[:mournmail_virus]
|
|
46
|
+
if virus
|
|
47
|
+
return unless yes_or_no?("Virus detected (#{virus}); really save?")
|
|
48
|
+
end
|
|
45
49
|
default_path = File.expand_path(part_file_name(part),
|
|
46
50
|
CONFIG[:mournmail_save_directory])
|
|
47
51
|
path = read_file_name("Save: ", default: default_path)
|
|
@@ -124,6 +128,10 @@ module Mournmail
|
|
|
124
128
|
end
|
|
125
129
|
|
|
126
130
|
def open_part(part)
|
|
131
|
+
virus = @buffer[:mournmail_virus]
|
|
132
|
+
if virus
|
|
133
|
+
return unless yes_or_no?("Virus detected (#{virus}); really open?")
|
|
134
|
+
end
|
|
127
135
|
if part.multipart?
|
|
128
136
|
raise EditorError, "Can't open a multipart entity."
|
|
129
137
|
end
|
|
@@ -116,12 +116,9 @@ module Mournmail
|
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
def
|
|
120
|
-
message =
|
|
121
|
-
|
|
122
|
-
raise EditorError, "No message found"
|
|
123
|
-
end
|
|
124
|
-
[Mail.new(Mournmail.read_mail_cache(message._key)), false]
|
|
119
|
+
def current_mail_reader
|
|
120
|
+
message = current_message
|
|
121
|
+
-> { [Mail.new(Mournmail.read_mail_cache(message._key)), false, nil] }
|
|
125
122
|
end
|
|
126
123
|
|
|
127
124
|
def next_message
|
data/lib/mournmail/summary.rb
CHANGED
|
@@ -130,7 +130,7 @@ module Mournmail
|
|
|
130
130
|
item = @uid_table[uid]
|
|
131
131
|
if item.cache_id
|
|
132
132
|
File.open(Mournmail.mail_cache_path(item.cache_id)) do |f|
|
|
133
|
-
[Mournmail.parse_mail(f.read), false]
|
|
133
|
+
[Mournmail.parse_mail(f.read), false, nil]
|
|
134
134
|
end
|
|
135
135
|
else
|
|
136
136
|
Mournmail.imap_connect do |imap|
|
|
@@ -141,13 +141,15 @@ module Mournmail
|
|
|
141
141
|
end
|
|
142
142
|
s = data[0].attr["BODY[]"]
|
|
143
143
|
mail = Mournmail.parse_mail(s)
|
|
144
|
+
virus = Mournmail.scan_virus(s)
|
|
144
145
|
spam_mailbox = Mournmail.account_config[:spam_mailbox]
|
|
145
|
-
if
|
|
146
|
-
|
|
146
|
+
if virus.nil? &&
|
|
147
|
+
(spam_mailbox.nil? ||
|
|
148
|
+
@mailbox != Net::IMAP.encode_utf7(spam_mailbox))
|
|
147
149
|
item.cache_id = Mournmail.write_mail_cache(s)
|
|
148
150
|
Mournmail.index_mail(item.cache_id, mail)
|
|
149
151
|
end
|
|
150
|
-
[mail, true]
|
|
152
|
+
[mail, true, virus]
|
|
151
153
|
end
|
|
152
154
|
end
|
|
153
155
|
end
|
|
@@ -55,9 +55,9 @@ module Mournmail
|
|
|
55
55
|
return if uid.nil?
|
|
56
56
|
summary = Mournmail.current_summary
|
|
57
57
|
Mournmail.background do
|
|
58
|
-
mail, fetched = summary.read_mail(uid)
|
|
58
|
+
mail, fetched, virus = summary.read_mail(uid)
|
|
59
59
|
foreground do
|
|
60
|
-
show_message(mail)
|
|
60
|
+
show_message(mail, virus)
|
|
61
61
|
mark_as_seen(uid, false)
|
|
62
62
|
Mournmail.current_uid = uid
|
|
63
63
|
Mournmail.current_mail = mail
|
|
@@ -98,8 +98,9 @@ module Mournmail
|
|
|
98
98
|
define_local_command(:summary_reply,
|
|
99
99
|
doc: "Reply to the current message.") do
|
|
100
100
|
|reply_all = current_prefix_arg|
|
|
101
|
+
read_mail = current_mail_reader
|
|
101
102
|
Mournmail.background do
|
|
102
|
-
mail =
|
|
103
|
+
mail, = read_mail.call
|
|
103
104
|
body = mail.render_text
|
|
104
105
|
foreground do
|
|
105
106
|
Window.current = Mournmail.message_window
|
|
@@ -111,7 +112,8 @@ module Mournmail
|
|
|
111
112
|
}.uniq.reject { |addr|
|
|
112
113
|
mail.from&.include?(addr)
|
|
113
114
|
}
|
|
114
|
-
insert("\
|
|
115
|
+
insert("\n" +
|
|
116
|
+
Mournmail.fold_header_field("Cc", cc_addrs.join(", ")))
|
|
115
117
|
else
|
|
116
118
|
insert(mail.reply_to&.join(", ") || mail.from&.join(", "))
|
|
117
119
|
end
|
|
@@ -129,7 +131,7 @@ module Mournmail
|
|
|
129
131
|
end
|
|
130
132
|
if !references.empty?
|
|
131
133
|
refs = references.map { |id| "<#{id}>" }.join(" ")
|
|
132
|
-
insert("\
|
|
134
|
+
insert("\n" + Mournmail.fold_header_field("References", refs))
|
|
133
135
|
end
|
|
134
136
|
end_of_buffer
|
|
135
137
|
push_mark
|
|
@@ -256,9 +258,9 @@ module Mournmail
|
|
|
256
258
|
|
|
257
259
|
define_local_command(:summary_view_source,
|
|
258
260
|
doc: "View source of a mail.") do
|
|
259
|
-
|
|
261
|
+
read_mail = current_mail_reader
|
|
260
262
|
Mournmail.background do
|
|
261
|
-
mail, =
|
|
263
|
+
mail, = read_mail.call
|
|
262
264
|
foreground do
|
|
263
265
|
source_buffer = Buffer.find_or_new("*message-source*",
|
|
264
266
|
file_encoding: "ascii-8bit",
|
|
@@ -306,8 +308,9 @@ module Mournmail
|
|
|
306
308
|
end
|
|
307
309
|
s = mails.map { |mail| mail.body.decoded }.join
|
|
308
310
|
mail = Mail.new(s)
|
|
311
|
+
virus = Mournmail.scan_virus(s)
|
|
309
312
|
foreground do
|
|
310
|
-
show_message(mail)
|
|
313
|
+
show_message(mail, virus)
|
|
311
314
|
Mournmail.current_uid = nil
|
|
312
315
|
Mournmail.current_mail = mail
|
|
313
316
|
end
|
|
@@ -408,7 +411,7 @@ module Mournmail
|
|
|
408
411
|
data&.each do |i|
|
|
409
412
|
uid = i.attr["UID"]
|
|
410
413
|
s = i.attr["BODY[]"]
|
|
411
|
-
if s
|
|
414
|
+
if s && Mournmail.scan_virus(s).nil?
|
|
412
415
|
cache_id = Mournmail.write_mail_cache(s)
|
|
413
416
|
Mournmail.index_mail(cache_id, Mail.new(s))
|
|
414
417
|
summary[uid].cache_id = cache_id
|
|
@@ -449,8 +452,8 @@ module Mournmail
|
|
|
449
452
|
|
|
450
453
|
define_local_command(:summary_show_thread,
|
|
451
454
|
doc: "Show the thread of the current mail.") do
|
|
455
|
+
message = current_message
|
|
452
456
|
Mournmail.background do
|
|
453
|
-
message = current_message
|
|
454
457
|
messages = Groonga["Messages"].select { |m|
|
|
455
458
|
m.thread_id == message.thread_id
|
|
456
459
|
}.sort([["date", :asc]])
|
|
@@ -499,10 +502,13 @@ module Mournmail
|
|
|
499
502
|
@buffer.to_s.scan(/^ *\d+(?=\*)/).map(&:to_i)
|
|
500
503
|
end
|
|
501
504
|
|
|
502
|
-
|
|
505
|
+
# Returns a lambda that reads the current mail.
|
|
506
|
+
# This method must be called in the foreground, and the returned
|
|
507
|
+
# lambda can be called in the background.
|
|
508
|
+
def current_mail_reader
|
|
503
509
|
summary = Mournmail.current_summary
|
|
504
510
|
uid = selected_uid
|
|
505
|
-
summary.read_mail(uid)
|
|
511
|
+
-> { summary.read_mail(uid) }
|
|
506
512
|
end
|
|
507
513
|
|
|
508
514
|
def scroll_up_or_next_uid
|
|
@@ -528,7 +534,7 @@ module Mournmail
|
|
|
528
534
|
end
|
|
529
535
|
end
|
|
530
536
|
|
|
531
|
-
def show_message(mail)
|
|
537
|
+
def show_message(mail, virus = nil)
|
|
532
538
|
message_buffer = Buffer.find_or_new("*message*",
|
|
533
539
|
undo_limit: 0, read_only: true)
|
|
534
540
|
message_buffer.apply_mode(Mournmail::MessageMode)
|
|
@@ -538,6 +544,7 @@ module Mournmail
|
|
|
538
544
|
message_buffer.beginning_of_buffer
|
|
539
545
|
end
|
|
540
546
|
message_buffer[:mournmail_mail] = mail
|
|
547
|
+
message_buffer[:mournmail_virus] = virus
|
|
541
548
|
window = Mournmail.message_window
|
|
542
549
|
window.buffer = message_buffer
|
|
543
550
|
end
|
|
@@ -667,7 +674,10 @@ module Mournmail
|
|
|
667
674
|
end
|
|
668
675
|
|
|
669
676
|
def current_message
|
|
670
|
-
|
|
677
|
+
get_message(selected_uid)
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def get_message(uid)
|
|
671
681
|
item = get_summary_item(uid)
|
|
672
682
|
message = Groonga["Messages"][item.cache_id]
|
|
673
683
|
if message.nil?
|
data/lib/mournmail/utils.rb
CHANGED
|
@@ -46,6 +46,16 @@ module Mournmail
|
|
|
46
46
|
HAVE_MAIL_GPG = false
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# Raised by :mournmail_virus_scan_hook when a virus is detected.
|
|
50
|
+
class VirusDetected < StandardError
|
|
51
|
+
attr_reader :virus_name
|
|
52
|
+
|
|
53
|
+
def initialize(virus_name = "unknown")
|
|
54
|
+
@virus_name = virus_name
|
|
55
|
+
super("Virus detected: #{virus_name}")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
49
59
|
def self.define_variable(name, initial_value: nil, attr: nil)
|
|
50
60
|
var_name = "@" + name.to_s
|
|
51
61
|
if !instance_variable_defined?(var_name)
|
|
@@ -106,7 +116,9 @@ module Mournmail
|
|
|
106
116
|
background(skip_if_busy: true) do
|
|
107
117
|
begin
|
|
108
118
|
imap_connect do |imap|
|
|
109
|
-
|
|
119
|
+
Timeout.timeout(CONFIG[:mournmail_imap_noop_timeout]) do
|
|
120
|
+
imap.noop
|
|
121
|
+
end
|
|
110
122
|
end
|
|
111
123
|
rescue => e
|
|
112
124
|
message("Error in IMAP NOOP: #{e.class}: #{e.message}")
|
|
@@ -199,6 +211,7 @@ module Mournmail
|
|
|
199
211
|
Timeout.timeout(CONFIG[:mournmail_imap_connect_timeout]) do
|
|
200
212
|
@imap = Net::IMAP.new(conf[:imap_host],
|
|
201
213
|
conf[:imap_options].except(:auth_type, :user_name, :password))
|
|
214
|
+
setup_tcp_keep_alive(@imap)
|
|
202
215
|
@imap.authenticate(auth_type, conf[:imap_options][:user_name],
|
|
203
216
|
password)
|
|
204
217
|
@mailboxes = @imap.list("", "*").map { |mbox|
|
|
@@ -211,11 +224,37 @@ module Mournmail
|
|
|
211
224
|
end
|
|
212
225
|
yield(@imap)
|
|
213
226
|
end
|
|
214
|
-
rescue IOError,
|
|
227
|
+
rescue IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError,
|
|
228
|
+
Timeout::Error, Net::IMAP::ByeResponseError
|
|
215
229
|
imap_disconnect
|
|
216
230
|
raise
|
|
217
231
|
end
|
|
218
232
|
|
|
233
|
+
# Detect dead connections at the kernel level (e.g. after a network
|
|
234
|
+
# switch, where reads block forever without an error).
|
|
235
|
+
# Net::IMAP does not expose its socket, so use its internal @sock.
|
|
236
|
+
def self.setup_tcp_keep_alive(imap)
|
|
237
|
+
sock = imap.instance_variable_get(:@sock)
|
|
238
|
+
return if sock.nil?
|
|
239
|
+
sock = sock.io if sock.respond_to?(:io) # OpenSSL::SSL::SSLSocket
|
|
240
|
+
sock.setsockopt(:SOCKET, :KEEPALIVE, true)
|
|
241
|
+
if Socket.const_defined?(:TCP_KEEPIDLE)
|
|
242
|
+
sock.setsockopt(:TCP, :KEEPIDLE, 60)
|
|
243
|
+
end
|
|
244
|
+
if Socket.const_defined?(:TCP_KEEPINTVL)
|
|
245
|
+
sock.setsockopt(:TCP, :KEEPINTVL, 10)
|
|
246
|
+
end
|
|
247
|
+
if Socket.const_defined?(:TCP_KEEPCNT)
|
|
248
|
+
sock.setsockopt(:TCP, :KEEPCNT, 3)
|
|
249
|
+
end
|
|
250
|
+
if Socket.const_defined?(:TCP_USER_TIMEOUT)
|
|
251
|
+
sock.setsockopt(:TCP, :USER_TIMEOUT, 90_000) # milliseconds
|
|
252
|
+
end
|
|
253
|
+
rescue StandardError
|
|
254
|
+
# Keep alive is best-effort; the NOOP timeout still detects dead
|
|
255
|
+
# connections without it.
|
|
256
|
+
end
|
|
257
|
+
|
|
219
258
|
def self.imap_disconnect
|
|
220
259
|
@imap_mutex.synchronize do
|
|
221
260
|
stop_keep_alive_thread
|
|
@@ -406,6 +445,24 @@ module Mournmail
|
|
|
406
445
|
File.read(path)
|
|
407
446
|
end
|
|
408
447
|
|
|
448
|
+
# Returns the virus name if a virus is detected, nil otherwise.
|
|
449
|
+
def self.scan_virus(data)
|
|
450
|
+
run_hooks(:mournmail_virus_scan_hook, data)
|
|
451
|
+
nil
|
|
452
|
+
rescue VirusDetected => e
|
|
453
|
+
foreground do
|
|
454
|
+
message("Virus detected: #{e.virus_name}")
|
|
455
|
+
end
|
|
456
|
+
e.virus_name
|
|
457
|
+
rescue => e
|
|
458
|
+
# Treat the mail as unscanned so that a broken scanner doesn't make
|
|
459
|
+
# mails unreadable.
|
|
460
|
+
foreground do
|
|
461
|
+
message("Virus scan failed: #{e.class}: #{e.message}")
|
|
462
|
+
end
|
|
463
|
+
nil
|
|
464
|
+
end
|
|
465
|
+
|
|
409
466
|
def self.write_mail_cache(s)
|
|
410
467
|
header = s.slice(/.*\r\n\r\n/m)
|
|
411
468
|
cache_id = Digest::SHA256.hexdigest(header)
|
data/lib/mournmail/version.rb
CHANGED
data/lib/mournmail.rb
CHANGED
|
@@ -2,6 +2,7 @@ require_relative "mournmail/version"
|
|
|
2
2
|
require_relative "mournmail/config"
|
|
3
3
|
require_relative "mournmail/faces"
|
|
4
4
|
require_relative "mournmail/utils"
|
|
5
|
+
require_relative "mournmail/header_folding"
|
|
5
6
|
require_relative "mournmail/message_rendering"
|
|
6
7
|
require_relative "mournmail/summary"
|
|
7
8
|
require_relative "mournmail/draft_mode"
|
data/mournmail.gemspec
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mournmail
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: '
|
|
4
|
+
version: '4'
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shugo Maeda
|
|
@@ -177,6 +177,20 @@ dependencies:
|
|
|
177
177
|
- - ">="
|
|
178
178
|
- !ruby/object:Gem::Version
|
|
179
179
|
version: '12.0'
|
|
180
|
+
- !ruby/object:Gem::Dependency
|
|
181
|
+
name: test-unit
|
|
182
|
+
requirement: !ruby/object:Gem::Requirement
|
|
183
|
+
requirements:
|
|
184
|
+
- - ">="
|
|
185
|
+
- !ruby/object:Gem::Version
|
|
186
|
+
version: '0'
|
|
187
|
+
type: :development
|
|
188
|
+
prerelease: false
|
|
189
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
190
|
+
requirements:
|
|
191
|
+
- - ">="
|
|
192
|
+
- !ruby/object:Gem::Version
|
|
193
|
+
version: '0'
|
|
180
194
|
description: A message user agent for Textbringer.
|
|
181
195
|
email:
|
|
182
196
|
- shugo@ruby-lang.org
|
|
@@ -185,8 +199,13 @@ executables:
|
|
|
185
199
|
extensions: []
|
|
186
200
|
extra_rdoc_files: []
|
|
187
201
|
files:
|
|
202
|
+
- ".claude/skills/pr/SKILL.md"
|
|
203
|
+
- ".claude/skills/push/SKILL.md"
|
|
204
|
+
- ".claude/skills/release/SKILL.md"
|
|
188
205
|
- ".github/workflows/push_gem.yml"
|
|
206
|
+
- ".github/workflows/test.yml"
|
|
189
207
|
- ".gitignore"
|
|
208
|
+
- CLAUDE.md
|
|
190
209
|
- Gemfile
|
|
191
210
|
- LICENSE.txt
|
|
192
211
|
- README.md
|
|
@@ -199,6 +218,7 @@ files:
|
|
|
199
218
|
- lib/mournmail/config.rb
|
|
200
219
|
- lib/mournmail/draft_mode.rb
|
|
201
220
|
- lib/mournmail/faces.rb
|
|
221
|
+
- lib/mournmail/header_folding.rb
|
|
202
222
|
- lib/mournmail/mail_encoded_word_patch.rb
|
|
203
223
|
- lib/mournmail/message_mode.rb
|
|
204
224
|
- lib/mournmail/message_rendering.rb
|
|
@@ -227,7 +247,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
227
247
|
- !ruby/object:Gem::Version
|
|
228
248
|
version: '0'
|
|
229
249
|
requirements: []
|
|
230
|
-
rubygems_version: 4.
|
|
250
|
+
rubygems_version: 4.0.16
|
|
231
251
|
specification_version: 4
|
|
232
252
|
summary: A message user agent for Textbringer.
|
|
233
253
|
test_files: []
|