[Emacs] GNUS: Emacs 的邮件系统,以 Outlook 邮箱为例
Table of Contents
简介
GNUS, Gnus Network User Services, 是 Emacs 的一个信息接收应用,支持接收和发送邮件、新闻,也能用于接收 RSS[1] 。我想换掉 Vivaldi 浏览器,转而用一个更加简洁、更加轻量的浏览器 Min -–— 有一说一,习惯了 Min 之后感觉真的很清爽,虽然 vivaldi 也能在外观和界面上做到,只是它内置的工具太多了。准备集成 Emac, 通过 GNUS 把邮件和 RSS 都搬到上边,这样 vivaldi 对我而言就没啥优势了。
GNUS 的文档《Gnus Manual》,写的非常详细 -–— 也就是非常复杂,而且它还涉及到各种信息通信的技术。我了解得少,只想要一个简单的配置,没有花时间看,后面空了再慢慢补上。
于是我四面八方搜罗资料,参考论坛的前辈的分享,终于让我写出了一个能用的配置: Emacs收发邮件完全操作指南: Send-Mail, Rmail and Gnus - Emacs-general - Emacs China 。
配置
首先说明的是,我目前的 Emacs 版本(28.2)已经内置了 GNUS 模块,直接 requrire
就能用了。
根据官方文档, GNUS 有自己的配置文件 ~/.gnus.el
,就像 Emacs 的 init.el
一样。
目前我的配置已经使用了 setup 的模块化配置,有一些设定好的参数在 .emacs.d/init.el
里面,同时为了保持配置的一贯性,我决定还是把大部分配置写在这里 [2] ;对于一些隐私信息如邮箱名字、 RSS 订阅列表等就写在 GNUS 的 init 中留在本地。
以下的配置写在 ~/.eamcs.d
或在 /.gnus.el
中都可以。
基本配置
(setq gnus-init-file "~/.gnus.el"
gnus-home-directory "~/.emacs.d/etc/gnus"
gnus-use-cache)
- 首先要告诉 GNUS, 它的初始化文件在哪里
- 设置 GNUS 的数据和配置的存放目录目录。它默认是家目录,如果不改,后续就会有各种奇怪的目录出现
- 使用缓存,可以离线查看缓存了的邮件
邮箱配置
前面给的论坛里面的分享是用 QQ 邮箱为例子,我用的是 Outlook, 我就以 Outlook 为例。
首先,你需要确定自己的邮箱是支持第三方的。大部分邮箱都支持,但还是有比如 Tutanota 就不支持。
然后去找到你用邮箱的配置,一般在搜索引擎上就行找到,关键词“xx邮箱 第三方配置”。
最好是在官网上看,如果看其他博主分享的话要注意发文时间,因为服务器地址端口可能会更新的。
得到如下信息:
- 收件服务器
- 协议
imap
- 地址
outlook.office365.com
- 端口
993
- 加密方式
ssl
- 协议
- 发件服务器
- 协议
smtp
- 地址
osmtp.office365.com
- 端口
587
- 加密方式
startssl
- 协议
收件服务器配置
我用的是 imap 的协议,如下:
(setq gnus-select-method
'(nnimap "outlook"
(nnimap-address "outlook.office365.com")
(nnimap-server-port 993)
(nnimap-stream ssl)))
发件服务器配置
(setq send-mail-function 'smtpmail-send-it
smtpmail-smtp-server "smtp.office365.com"
smtpmail-smtp-service 587)
不需要配置加密方式。
身份验证配置
身份验证信息是写在一个单独文件里,变量 auth-sources
说明了验证文件的位置,默认是 ("~/.authinfo" "~/.authinfo.gpg" "~/.netrc")
, 你可以根据自己的需要改,或者就写在默认的 ~/.authinfo
也行。
machine <server> login <[email protected]> password <your password> port <port>
machine <server> login <[email protected]> password <your password> port <port>
有两条记录,分别是收件服务器和发件服务器的验证。
machine
后边写上服务器的地址login
后边跟上你的邮箱地址password
后边是密码port
后边是对应的端口
以前面的信息为例就是:
machine outlook.office365.com login <user>@outlook.com password <password> port 993
machine smtp.office365.com login <user>@outlook.com password <password> port 587
可以使用 Emacs 自带的加密系统加密:
- 新建一个
~/.authinfo.gpg
- 在首行键入
-*- epa-file-encrypt-to: nil -*-
- 输入上面那两行验证条目
- 保存,然后会要求输入密码
使用
至此,一个简单的邮箱配置就完成了。打开 Emacs, 然后 M-x gnus RET
就可以进入到 GNUS 的界面了。
GNUS 结构
GNUS 有三大部分构成: Server, Group 和 Article. 简单理解:
- Server 就是一个服务器,每不同的邮箱帐号就对应不同的 Server 。
- Group 是服务器 Server 下的文件夹
- Article 是组 Group 下的邮件
其结构如下:
graph TD; A[Server:outlook.com] --> B[Group:Inbox] A --> C[Group:Send] A --> D[Group:Trush] B --> B1[Article] B --> B2[Article] B --> B3[Article] C --> C1[Article] C --> C2[Article] C --> C3[Article] D --> D1[Article] D --> D2[Article] D --> D3[Article]
由此也衍生出了不同的 buffer 用于显示不同的信息:
*Server*
, 服务器列表,用于展示服务器*Group*
, 组列表,用于展示服务器下的组*Summary*
, 文章列表,用于展示组下的文章*Article*
,文章的具体内容
Group
组列表,展示当前服务器下所有的组。区分订阅(subscript)和可见(visible)。
- 订阅是告诉 GNUS 那些组需要去获取(fetch)。比如 Outlook, 它内置了很多文件夹如笔记(Notes)、存档、同步等,我删不掉且不会用到,那么我㠇可以取消订阅它 (unsubscribe), 之后就不会去获取这个文件夹的内容了。默认是所有文件夹都订阅的。在一个新帐号刚配置时,会默认订阅该帐号下所有的文件夹。订阅设置接下来就会说到。
- 可见是指,对于订阅的组,哪些会展示在 Group Buffer 上。默认情况下,有未读邮件的组可见,其它组不可见。但我有点不习惯这个,在组参数那里会说怎么调。
组操作
组订阅
- S t (或 u) 订阅开关,如果已经订阅设为不订阅,如果不订阅设为订阅。
- S s (或 U) 弹出 minibuffer, 选择一个组,然后使用订阅开关
GNUS 是通过组的一个 level 来判断是否要订阅或者不订阅某个组。所以如果不想订阅,可以在配置里面写:
(setq gnus-unsubscribed-groups '("Notes"
;; ...
))
(defun my/gnus-handle-unscribed-groups ()
(dolist (group gnus-unsubscribed-groups)
(gnus-group-change-level group
gnus-level-unsubscribed
gnus-level-default-subscribed)))
(add-hook 'gnus-group-prepare #""my/gnus-handle-unscribed-groups)
此时依然可以在 Buffer 里面看到它们,但是会多一个 U
的标志,表示不订阅。
组光标移动
- n 移动到下一个含有未读消息的组
- p 移动到上一个含有未读消息的组
- N 移动到下一个组
- P 移动到上一个组
- j 选择跳转到一个组,可以输入字符搜索,可以跳转到未显示的组
- . 移动到第一个含有未读消息的组
组打开退出
- SPC 打开组,进入
*Summary* buffer
, 同时自动打开第一个未读的消息。列表只显示了未读的或者标记了的信息。 - RET 打开组,不自动打开消息
- q 询问是否缓存并退出 GNUS
组标记
- c 标记当前组内 *没有被标记的文章为已读
- C 标记当前组内 *所有文章为已读
- M m (或 #) 标记当前组
- M u (或 M-#) q 取消标记当前组
- M U 取消所有标记
- M b 标记当前 buffer 的所有组
组管理
管理组都会向服务器发送请求,比如我要新建一个组,那么就会发请求去服务器那边告诉它这边要新建一个组。如果 Outlook 的回收站名字叫 Junk, 我想重命名它,然后后收到服务器那边的拒绝 -–— 这个是它默认的文件夹,是不让改的。
- G m 添加一个组,会被询问名字、方法(协议)、地址等信息
- G r 重命名一个组
- G p 编辑组参数
- G R 添加一个 RSS 组。不会发送到服务器
- G DEL 删除组,字面意义上的删除。如果给了前缀 C-u, 删除包括里面的文章。慎用!!!
组显示
组可见是指能不能在 *Group*
buffer 中显示,并不是它被删除。默认情况下,如果一个组没有了未读文章就会被隐藏,使得我们的注意力可以集中在那些未处理的消息上。
- A s (或 l) 显示含有未读文章的组,默认下只显示等级 5 及以下的组——一般组的等级都是 3
- A u (或 L) 显示所有组,不管含不含有未读
- A m 显示名字正则匹配且含有未读的组。如果输入空就是还原
- A M 显示所有名字正则匹配的组
组排序
- G S a 按名字以字母表顺序排序
- G S u 按名字以字母表顺序排序含有未读
- G S l 按等级排序
- G S v 按分数排序
组其他操作
- ^ 打开服务器列表,进入
*Server* buffer
- a 新建一封信息
- g 刷新服务器
- R 重启 GNUS
组参数
本质上是一个列表,如:
((to-address . "[email protected]")
(auto-expire . t))
每个组都会有一个这样的列表
常用参数:
visible
, 布尔值,如果设为t
,那么该组一直可见,即使没有未读文章display
, 进入该组时,展示的文章数量all
, 展示所有文章- 整数 N, 表示展示最多 N 篇文章
default
, 展示默认可见的文章,包括未读和标记,过期的不展示
comment
, 一段注释,字符串类型,即要加双引号charset
, 字符集
组参数可以在配置文件中批量配置,通过设置变量 gnus-parameters
来实现。
(setq gnus-parameters
'(("mail\\..*" (display . all))
("list\\..*"
(total-expire . t)
(broken-reply-to . t))))
第一个参数是用于正则匹配,名字匹配到的组会加上后续的参数。 gnus-parameters-case-fold-search
表明了匹配时是否忽略大小写,默认是忽略。
visible
不能通过这种方式设置,有一个变量 gnus-permanently-visible-groups
专门用于设置可见组,它是一个字符串,用于正则匹配的,可以这样配置:
(setq gnus-permanently-visible-groups
(string '("pattern-1"
"pattern-2"
; ...)
"\\|"))
话题
话题 Topic, 暂时用不到。
Summary Buffer
文章列表(字面翻译的话就叫“纲要”吧),展示当前组下所有的文章。默认情况下只展示未读文章。
纲要操作
纲要打开与退出
- SPACE/RET 在文章尚未打开时,这两个就是打开文章。如果打开后会变成其它功用
- q 退出当前组
c 标记所有未标记的文章为已读,并退出
一般用不到,在读完当前组的消息后,再次按翻页或者下一条会自动跳转到下一个有未读消息的组
纲要光标移动
- n 移动到下一未读文章
- p 移动到上一未读文章
- N 移动到下一篇文章
- P 移动到上一篇文章
文章滚动与操作
在 *Summary*
buffer 下打开文章会弹出一个 *Article*
buffer, 但光标依然停留在 *Summary*
, 也就是还能用 n/p/N/P 来选择不同文章,文章的 buffer 内容也会跟着相应变化。
此时一些键盘映射已经改变,使得光标即使在纲要的 buffer ,也能对文章 buffer 进行翻页操作。
- SPACE 向下翻一页,如果已经达到底部,那么再按 SPACE 就会自动跳转到下一篇未读文章
- DEL 向上翻一页
- RET 向下滚动一行
- M-RET 向上回滚一行
- < 回到首行
- > 移动到底行
- g 重新获取(刷新)文章
- s 在文章中渐进搜索( I-search ),使用
C-s
来向前继续搜索,C-r
来向后搜索 - h 选择文章,也就是把光标移动到文章的buffer
文章显示
- / / (或 / s) 展示相同主题(subject)的文章,加前缀 C-u 则是排除该主题
- / a 展示相同作者的文章,加前缀 C-u 则是排除该主题
- / R 展示相同收件人(recipient)的文章,加前缀 C-u 则是排除该主题
- / A 展示 To, From, Cc 有给定地址的文章,加前缀 C-u 则是排除该主题
- / u (或者 x) 展示未读文章,加前缀 C-u 则展示严格未读的,被标记为标记(ticked)和休眠的都会排除
- / m 展示给定标记的文章
- / t 展示给定天数(days)之内的文章,加前缀 C-u 则是给定天数之外
- / r 展示已回复的文章,加前缀 C-u 则是未回复
- / b 展示(搜索)文章内容匹配给定字符的文章,加前缀 C-u 就是排除。可能会很慢
- / h 展示(搜索)文章头部匹配给定字符的文章,加前缀 C-u 就是排除。可能会很慢
邮件操作
- B DEL 删除当前邮件
- B m 移动当前邮件
- B c 复制当前邮件
文章搜索
- M-s 向后搜索
- M-r 向前搜索
- M-S 重复向后搜索
- M-R 重复向前搜索
文章发送
邮件撰写
- m 准备一封邮件进行撰写。使用默认样式。
- S i 准备一则新闻,默认会发送到当前组。
- S D b 重新准备未发送成功的邮件。
- S D r 重新准备未发送成功的邮件,需要重新指定地址。
- S D e 重新准备未发送成功的邮件,需要重新编辑内容。
文章回复
- r 回复当前文章的作者
- R 回复当前文章的作者,并附上文章的原始内容
- S w 广泛回复,回复给文章中的 To, From 和抄送列表的所有人
- S W 广泛回复,并附上文章的原始内容
文章撰写
- S p (或 a) 准备一篇文章,默认发到当前组。
- S f (或 f) 发送一篇后续(followup)到当前文章
- S F (或 F) 发送一篇后续(followup)到当前文章,并附上当前文章的原始内容
文章标记
文章标记样式
- '!' 标记(ticked),它意味着不管读没读过该文章都一直可见,但是会过期。
- '?' 休眠,它只有在文章有后续更新时才会显示
- 'r' 已读,不一定真的读了,它是被用户标记为已读状态
- 'R' 真已读,真的被浏览了
- 'O' 过时,之前被标记为已读,但现在已经有更新
- 'K' 删除,被标记的
- 'X' 删除,指文件已经不存在
- 'C' 赶上(catchup)
- 'G' 取消
- 'M' 重复
- 'E' 过期
文章标记操作
- ! 标记当前文章为标记(ticked)
- ? 标记当前文章为休眠
- M-u 标记当前文章为未读
- d 标记当前文章为已读
- D 标记当前文章为已读,光标向上移动一行
- M C-c 标记所有文章为已读
- E 标记当前文章为过期
- M b 给当前文章设置书签
- M B 移除当前文章的所有书签
文章排序
默认文章排序可以通过初始化变量 gnus-article-sort-functions
, 它是一个列表,包含了各种排序函数,最后一个函数是主要的排序函数。内置的函数有:
- gnus-article-sort-by-number 按序号排序,默认值,建议是第一个
- gnus-summary-sort-by-most-recent-number 按最近序号排序
- gnus-article-sort-by-author 按作者排序
- gnus-summary-sort-by-recipient 按收件人排序
- gnus-article-sort-by-subject 按主题排序
- gnus-article-sort-by-date 按日期排序
- gnus-summary-sort-by-most-recent-date 按最近日期排序
- gnus-article-sort-by-score 按分数排序
- gnus-summary-sort-by-lines 按文章行数排序
- gnus-summary-sort-by-chars 按文章子树排序
- gnus-article-sort-by-random 随机排序
我的配置是:
(setq gnus-article-sort-functions
'(gnus-article-sort-by-number
gnus-summary-sort-by-most-recent-date))
Article Buffer
文章内容的 buffer, 用于展示文章的内容(废话)。
- SPACE 向下滚动一页
- DEL 向上滚动一页
- C-c C-m 准备一封回信给光标附近的地址
- h (或 s) 光标在回到
*Summary*
和*Article*
间切换 - TAB 移动到下一个可点击处
- M-TAB 移动到上一个可点击处
- R 准备一篇回复给当前文章,并附上文章内容。如果选定了区域,只附上选定的内容
- S W 准备一篇广回复
- F 准备一篇后续
信息发送
在文章发送那里写好邮件,通过 C-c C-c
发送最后一封编辑的邮件
定时发送
需要在配置里面加上:
(gnus-delay-initialize)
它会初始化 delay 相关的包,和定制键盘映射。
在编辑邮件时 C-c C-j
来定时发送邮件。
定时发送可以设置的选项:
- 一段时间,如“42d”,那么就会在 42 天之后发送。有 m (分钟), h (小时), d (天), w (周), M (月), Y (年) 。
- 具体日期,格式“YYYY-MM-DD”,然后会在
gnus-delay-default-hour
这个时间点发送,默认是 8:00 am. - 具体时间,格式“hh:mm”, 24 小时制。如果当前时间在其之前,那么今天到点就发;否则,明天发。
它会在邮件头上加上一个头 gnus-delay-header
,默认是“X-Gnus-Delayed”,它的值是要发送的日期。
草稿
对于一封正在写的信,可以通过 C-x C-s
即 buffer 保存键把其保存到草稿,即 nndraft:drafts
组中,进入之后,可以进行的操作:
- D e 编辑该草稿
- D s 发送当前草稿
- D S 发送所有草稿
- D t 开关发送标志
- B DEL 删除当前草稿
RSS
目前我的做法是在配置文件中维护一份 RSS 列表,在每次启动 GNUS 之后检查,如果没有的话自动添加。
(defvar rss-list
'(("Emacs China - 最新话题" . "https://emacs-china.org/latest.rss")
;; ...
))
;; 原生版本
(add-hook 'gnus-group-prepare-hook
#'(lambda ()
(dolist (it rss-list)
(unless (gnus-group-entry (concat "nnrss:" (car it)))
(let ((title (car it))
(href (cdr it)))
(gnus-group-make-group title '(nnrss ""))
(push (list title href title) nnrss-group-alist))))
(nnrss-save-server-data nil)))
;; dash.el 版本
(add-hook 'gnus-group-prepare-hook
#'(lambda ()
(--map-when
(not (gnus-group-entry (concat "nnrss:"
(car it))))
(let ((title (car it))
(href (cdr it)))
(gnus-group-make-group title '(nnrss ""))
(push (list title href title) nnrss-group-alist))
rss-list)
(nnrss-save-server-data nil)))
通过 Feeddd 获取微信公众号
微信公众号自身不提供 RSS, 但是还是有高手做了项目,比如 Feeddd. 但是我发现 Feeddd 的 rss 都是只有个标题和一条超链接,点击超链接之后装到微信官方的站点去查看,甚是不便。于是我便根据 Emacs 内置的浏览器 EWW 获取文章内容, EWW 会把文章内容渲染好之后输出到一个 buffer ,再把它覆盖到邮件里——只是改了 buffer 内容,不会真地去改变 rss 原来的内容。
但是图片显示不出来。这是 EWW 的问题,因为它不加载 js ——如果图片是 HTML 标签就会加载;如果是通过 js 插入的就不行。而微信属于后者。我觉得懒猫大神的 EAF 应该可以做到。
(defun my/gnus--wechat-need-fetch (group)
"Check the gourp's uri. GROUP is a string"
(let ((uri (cdr (assoc-string group rss-list))))
(and uri
(string-match-p "api.feedd" uri))))
(defvar my/eww--sig nil)
(defun my/eww--set-sig ()
"Set singal when EWW is loaded."
(setq my/eww--sig t))
(defun my/gnus-fetch-content-from-wechat ()
"Fetch content from wechat link in the posts
TODO: Images don't show up, while other sites do."
(when (and gnus-article-current
(string-match-p "^nnrss" (car gnus-article-current))
(my/gnus--wechat-need-fetch nnrss-group))
(save-excursion
(with-current-buffer gnus-article-buffer
(re-search-forward "^link$")
(backward-char)
(let ((uri (get-text-property (point) 'shr-url))
(res ""))
(save-excursion
(add-hook 'eww-after-render-hook #'my/eww--set-sig)
(eww uri)
;; 我没找到 EWW 渲染完成的接口
;; 于是写了个计时器,每秒检查一次,5 秒超时
(with-timeout (5 nil)
(while (not my/eww--sig)
(sleep-for 1))
(setq res (buffer-string)
my/eww--sig nil))
(remove-hook 'eww-after-render-hook #'my/eww--set-sig))
(when (length> res 0)
(read-only-mode -1)
(beginning-of-line)
(delete-to-end-of-line)
(insert res)
(read-only-mode 1)))))
(quit-window)))
;; 添加钩子
(add-hook 'gnus-article-prepare-hook #'my/gnus-fetch-content-from-wechat)
后记
目前这些操作已经完全满足我的日常使用场景了,如果后续有新的体会或改动的话再更新。