[Emacs] GNUS: Emacs 的邮件系统,以 Outlook 邮箱为例

简介

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

同时还有陈斌老师的教程: mastering-emacs-in-one-year-guide/gnus-guide-en.org at master · redguardtoo/mastering-emacs-in-one-year-guide · GitHub

配置

首先说明的是,我目前的 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 自带的加密系统加密:

  1. 新建一个 ~/.authinfo.gpg
  2. 在首行键入 -*- epa-file-encrypt-to: nil -*-
  3. 输入上面那两行验证条目
  4. 保存,然后会要求输入密码

使用

至此,一个简单的邮箱配置就完成了。打开 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)

后记

目前这些操作已经完全满足我的日常使用场景了,如果后续有新的体会或改动的话再更新。

Powered by Org Mode.