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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 410f91625278d6121b3f4f0c2253595b294e66b4a0149ce6af53afaaac8a2747
4
- data.tar.gz: 2e052315c79594fc4a7946142efce5ff3f04b288e223c4d569f6b7cee423e95d
3
+ metadata.gz: a700bf135fb4201e2dc7ee68345301d311c133e7f16b9272d2b3823dd09c622e
4
+ data.tar.gz: a67ab7f0e246de1727b86bd4f42d4de76342216aebce7a62e286d478242564be
5
5
  SHA512:
6
- metadata.gz: d3e8e9a07573cacb0f5de41c5055589040a75a6ef2e9b8315e86744efea7f0c11b74cbed2d4c0e92bf4cee724bad183439d5c61b4a7b6f024eb48afe26005db4
7
- data.tar.gz: a51657c1ec942e4dd19f8982bfec92566f77c505b25988dd72dd20d8a1869a73ecbe752b682ca0cdae08c5fc5981b710b9b0e2df49f7e9ebe4b8d701e5b8f5a5
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 install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
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
- task :default => :spec
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
@@ -23,6 +23,7 @@ module Textbringer
23
23
  "Cc"
24
24
  ]
25
25
  CONFIG[:mournmail_imap_connect_timeout] = 10
26
+ CONFIG[:mournmail_imap_noop_timeout] = 15
26
27
  CONFIG[:mournmail_keep_alive_interval] = 60
27
28
  case RUBY_PLATFORM
28
29
  when /mswin|mingw/
@@ -54,7 +54,9 @@ module Mournmail
54
54
  when "PGP-Encrypt"
55
55
  pgp_encrypt = val.strip == "yes"
56
56
  else
57
- m[name] = val
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?
@@ -1,11 +1,11 @@
1
1
  module Textbringer
2
- Face.define :seen, foreground: "blue"
3
- Face.define :deleted, foreground: "green"
4
- Face.define :answered, foreground: "blue"
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, foreground: "magenta", bold: true
8
- Face.define :quotation, foreground: "yellow"
9
- Face.define :header_end, foreground: "yellow"
10
- Face.define :mime_part, foreground: "blue", bold: true
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 read_current_mail
120
- message = @buffer[:messages][@buffer.current_line]
121
- if message.nil?
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
@@ -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 spam_mailbox.nil? ||
146
- @mailbox != Net::IMAP.encode_utf7(spam_mailbox)
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 = read_current_mail[0]
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("\nCc: " + cc_addrs.join(", "))
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("\nReferences: " + refs)
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
- uid = selected_uid
261
+ read_mail = current_mail_reader
260
262
  Mournmail.background do
261
- mail, = read_current_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
- def read_current_mail
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
- uid = selected_uid
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?
@@ -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
- imap.noop
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, Errno::ECONNRESET
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)
@@ -1,3 +1,3 @@
1
1
  module Mournmail
2
- VERSION = "3"
2
+ VERSION = "4"
3
3
  end
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
@@ -34,4 +34,5 @@ Gem::Specification.new do |spec|
34
34
 
35
35
  spec.add_development_dependency "bundler"
36
36
  spec.add_development_dependency "rake", ">= 12.0"
37
+ spec.add_development_dependency "test-unit"
37
38
  end
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: '3'
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.1.0.dev
250
+ rubygems_version: 4.0.16
231
251
  specification_version: 4
232
252
  summary: A message user agent for Textbringer.
233
253
  test_files: []