moonsieve

    Bounded offline Sieve mail-filter analysis and execution in MoonBit

    sieve
    email
    interpreter
    offline
    rfc5228
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    17 hours ago
    Downloads
    3

    #MoonSieve

    MoonSieve 是 MoonBit 编写的 Sieve 邮件规则离线解释器和回归工具。输入规则、归一化邮件上下文及测试夹具,输出投递计划、命中轨迹和规则变更差异;不会发送、删除邮件,也不连接邮件服务器。

    它解决的是“规则上线前,哪些邮件会被移动、转发或静默丢弃”的问题,而不是再次实现 MIME、SMTP 或 IMAP。适合已有邮件解析层的 MoonBit 应用、规则编辑器和维护邮件分流策略的开发者。当前为 0.1.0:有明确边界的工具库,不宣称完整 RFC 一致性、生产邮件服务器替代品或安全认证。

    #快速运行

    需要 MoonBit 和 Node.js 22。CI 固定使用 MoonBit 编译器版本 0.10.4+2cc641edf 及配套 core,避免编译器升级使验证结果不可复现。MoonBit 安装说明见 官方文档

    git clone https://github.com/Han-Wentao/moonsieve.git cd moonsieve moon run cmd/main --target js -- run examples/list.sieve examples/mail.json moon run cmd/main --target js -- replay examples/list.sieve examples/cases.json moon run examples/embed --target js

    首条命令将返回 fileinto Lists/MoonBitkeep 两个动作::copy 不取消隐式保留。第二条命令验证“列表邮件归档并保留”和“普通邮件仍在收件箱”两种预期。

    作为库依赖使用:

    moon add Han-Wentao/moonsieve

    调用包的 moon.pkg

    import {
    "Han-Wentao/moonsieve" @sieve,
    }

    #三个完整工作流

    1. 邮件列表归档:邮件应用将 MIME 层展开、解码后的 List-Id 等头字段和当前收件人交给库,执行 examples/list.sieve,得到带 :copy 的归档动作及保留动作。宿主决定是否真正投递,不会因为验证规则而触碰用户邮件。
    2. 变更前回归:管理员用相同 JSON 夹具分别回放新旧规则,再执行 diff,逐封看到新增转发、失去全部投递途径和目的地变化;不同的计划返回退出码 1,可阻止未审核策略合并。有限夹具不是对所有可能邮件的安全证明。
    3. 规则编辑器预览:开发者用 compile 检查未执行分支中的命令和扩展声明,按诊断范围标红;随后用用户选定的归一化邮件预览命中轨迹和动作,区分显式丢弃、显式保留及隐式保留。无需在编辑器中启动 SMTP/IMAP 服务。

    #可嵌入 API

    以下主体与 examples/embed/main.mbt 的可运行流程一致。调用点需要处理 SieveError;示例中的地址和邮件都是人工合成数据。

    let program = @sieve.compile(
    "require [\"fileinto\", \"copy\"]; if header :contains \"List-Id\" \"moonbit\" { fileinto :copy \"Lists/MoonBit\"; }",
    )
    let message = @sieve.Message::new(
    [{ name: "List-Id", value: "dev.moonbit.example.org" }],
    2048,
    envelope_from="sender@example.org",
    envelope_to=["reader@example.org"],
    )
    let plan = program.run(message)
    println(plan.as_json(include_trace=false, redact_destinations=true).stringify())

    • compile(source, limits?) -> Program:词法、递归下降解析、所有分支的静态命令/参数/能力检查;可复用编译结果。不会在编译时求出动态目的地的合法性,运行仍可能失败。
    • Program.run(message, options?) -> Plan:执行一次,变量和捕获不跨邮件保留。失败抛出诊断,不返回半份动作计划。输入数组会复制;返回值的数组由调用方拥有。
    • test_expression:独立条件预览;显式传入扩展能力,仍检查邮件和执行预算。
    • Program.replaycompare_replays:有序夹具回放、期望校验及规范化动作集合差异;对比要求完全相同的夹具 ID 顺序。
    • Program.audit:不可达路径、恒假条件、无条件丢弃/转发和宽泛匹配的提示。不进行全程序安全证明,不推断动态变量目的地。
    • format_script:语法格式化(不是静态语义检查),去除注释、规范缩进和数字字面量,保留字符串内容。与 compile 组合使用。
    • Diagnostic.render/source_range/as_json:诊断展示和编辑器坐标。机器逻辑按 code 判断,勿匹配英文展示文案。
    • 完整导出 API 由 moon info 生成,见 pkg.generated.mbti。内部 AST 可用于工具开发,但只有经 compile 生成的 Program 才能运行。

    #输入契约

    { "headers": [ {"name": "List-Id", "value": "dev.moonbit.example.org"}, {"name": "Subject", "value": "MoonBit 开发讨论"} ], "size": 2048, "envelope": {"from": "sender@example.org", "to": ["reader@example.org"]} }

    • headers 和非负整数 size 必填。重复头字段必须保留为多条数组元素。
    • 头字段值必须已展开折行、已完成宿主需要的字符集/encoded-word 解码,不能含 CR、LF 或 NUL。库不读取原始 EML,不猜测 MIME 编码。
    • size 是原邮件八位字节数,由宿主提供;不是 JSON 长度,也不是字符串长度,库无法核实宿主提供的原始大小。
    • envelope 可省略;缺省发件路径为空,收件人列表为空。实际部署应传入运行该规则的那个收件人;to 最多一个非空地址。多收件人邮件请拆成多个独立上下文,不把整封邮件所有收件人暴露给一份用户规则。
    • 归一化后头字段名、值和信封总长受 source_chars 约束。JSON 拒绝未知字段、类型错误和非整数大小;重复 JSON 对象键沿用 core JSON 的行为,不提供重复键检测。

    夹具是对象数组,每项有唯一 idmessage,另可选 expectedexpected_error,两者不可同时存在。动作写法见 examples/cases.json。省略期望表示只观察执行,不表示已通过业务断言。

    expected 比较规范化动作集合而非顺序;expected_error 精确匹配错误码。规则差异只比较最终动作集合和错误码,不比较轨迹顺序、计数器或隐式/显式来源。

    #支持范围与兼容性边界

    层面已实现限制
    语法# 和块注释、引号字符串、text: 点转义、多值字符串、K/M/G 数字、if/elsif/else、not/anyof/allof无循环、include、用户函数;LF 输入按 CRLF 字符串换行规范化
    基础测试true/false、exists、size、header、address、envelopeenvelope 仅 from/to;字段名为静态 from/to,不支持变量生成信封字段名
    比较:is、:contains、:matches、i;octet、i;ascii-casemap输入为已解码 Unicode 字符串;? 匹配一个 Unicode 标量,不是任意原始字节;不做 Unicode 不区分大小写比较
    动作keep、discard、stop、fileinto、redirect只返回计划,不承担投递重试、权限校验或目的邮箱存在性检查
    copyfileinto/redirect :copy不取消已存在的隐式 keep,也不重新启用先前被取消的 keep
    variablesset、单次变量展开、6 种修改器、0..9 捕获不递归替换;超长值报错而不截断,与 RFC 5229 的建议不同;超过 9 的捕获引用运行时报错
    subaddress:user、:detail,可配置单个 ASCII 分隔符缺失 detail 与空 detail 分开;默认 +,由宿主选定站点语义
    relational:value、:count、6 种关系、i;ascii-numeric数字按前缀数字串比较,不转整数;非数字为无穷大;数值比较器不支持 contains/matches
    地址提取常见 ASCII addr-spec、引号本地部分、显示名、组、嵌套注释不是完整 RFC 5322 解析器;不支持 EAI/IDNA/过时路由语法,不能作为 SMTP 地址有效性认证器
    工具格式化、带范围诊断、建议式静态审计、回放、差异、JSON不支持 body、vacation、regex、imap4flags、spamtest、ManageSieve 等扩展

    需要扩展的脚本必须先 require。未知能力/命令/标签报错,不静默跳过。以上是本版本实现清单,不是宣称完整实现每个 RFC 的全部要求;导出 supported_capabilities 也仅针对该离线实现。

    关键语义:

    • 没有取消隐式保留的动作时,结束自动产生 Keep;无 :copy 的 fileinto/redirect、keep 和 discard 取消隐式保留。
    • discard 不是立即停止;之后的有效投递仍保留。只有没有其他投递动作时才输出 Discard。stop 停止后续规则,但不凭空取消隐式保留。
    • 同一 fileinto 目的地去重,INBOX 大小写等价;转发地址域名大小写等价、本地部分保持大小写。不得据此假定所有邮箱提供商有相同去重策略。
    • header :count 数头字段;address :count 数可提取邮箱而非组名,忽略地址部分选择;string :count 不数空串;空 envelope from 计 0。
    • Span.start/end 是从 0 开始的 Unicode 标量位置,end 不包含;line/column 从 1 开始。编辑器需要 UTF-16 时使用 source_range 转换,不直接混用偏移。终端排版不保证东亚宽字符精确列宽。

    #CLI

    moon run cmd/main --target js -- help moon run cmd/main --target js -- lint SCRIPT.sieve moon run cmd/main --target js -- format SCRIPT.sieve moon run cmd/main --target js -- run SCRIPT.sieve MESSAGE.json moon run cmd/main --target js -- replay SCRIPT.sieve CASES.json moon run cmd/main --target js -- diff BEFORE.sieve AFTER.sieve CASES.json

    除 help/format 外,标准输出为 JSON;format 仅输出文本,不覆盖原文件。没有任何命令执行真实邮件操作。

    退出码意义
    0成功,且已声明的期望通过;diff 无变化
    1回放业务期望不符,或 diff 存在动作/错误差异
    2参数、文件、编译或非预期执行错误

    CLI 只支持 JS/Node,单输入文件上限 1 MiB,仅接受普通文件和有效 UTF-8;根库支持 wasm-gc、wasm、js、native。CLI 输出默认含目的地,注意日志权限;嵌入式调用可通过 redact_destinations=true 脱敏。静态审计输出含规则中的字面量目的地,并非脱敏接口。

    #算法和资源边界

    递归下降构造带位置 AST,全分支能力检查之后解释执行。布尔条件短路;通配符用动态规划,不把用户模式拼接成正则,不存在指数级正则回溯。普通匹配用两行表;捕获模式使用受预算限制的完整表和贪婪重建。

    默认限制
    脚本或 JSON 文本 / 归一化邮件总长262144 UTF-16 单元
    单字符串65536 UTF-16 单元
    Token / 语法嵌套深度32768 / 64
    执行步 / 比较与数据收集预算100000 / 2000000
    不同投递动作 / 头字段 / 命名变量128 / 1000 / 128
    JSON 容器嵌套32
    回放夹具 / 总执行步 / 总比较预算1000 / 5000000 / 100000000

    可通过 Limits 下调或在安全上限内调整。0..9 捕获与命名变量分别计数。运行失败的回放用例保守扣除整份分配预算;总预算耗尽后的用例标为 replay.budget,不继续执行。资源计数不是精确 CPU 时间/进程内存承诺;高风险服务仍需进程级超时、内存限制和租户隔离。

    #验证与维护

    node tools/verify.mjs # 已安装 C 编译器的环境,包括 CI: node tools/verify.mjs --native # 独立查看源码统计,不将测试、注释、示例、生成文件、JS FFI 算作实现: python tools/count_loc.py

    验证入口执行格式检查、生成接口一致性、四后端严格检查、wasm-gc/wasm/js 测试和真实 Node CLI 子进程测试。--native 额外运行 native 测试;无 C 编译器时默认入口明确提示跳过 native 执行,不能据此宣称 native 测试通过。GitHub Actions 使用 Ubuntu 的 C 编译器执行完整入口。

    测试包括语法/语义拒绝、Unicode 坐标、空字段/空发件路径、隐式 keep、copy、变量捕获、预算耗尽、动作去重、回放及 API 隔离;用独立递归参考算法穷举 10571 组短字符串/通配模式,同时对照普通 DP 和捕获 DP。这个有限域测试不等同于形式证明,也不代表完整标准一致性或覆盖率百分比。

    维护时将一个完整功能与回归用例一起提交,运行上述验证,不为行数或次数拆分提交。若扩展改变输入语义、诊断码或投递去重规则,应同步更新本文和夹具。安全问题请先联系 Han Wentao(42673188@qq.com),不要在公开 issue 上传真实邮件或密钥。

    #目录

    • lexer.mbt / syntax.mbt / compile.mbt:前端和能力验证。
    • matching.mbt / variables.mbt / relational.mbt / subaddress.mbt:比较、扩展和资源预算。
    • message.mbt / evaluate.mbt / execute.mbt / delivery_identity.mbt:上下文、执行和动作语义。
    • format.mbt / diagnostics.mbt / audit.mbt:开发工具。
    • json_io.mbt / replay.mbt / service.mbt:互操作、回放和命令分发。
    • cmd/main:仅负责文件读取、参数及退出码的 Node FFI;规则逻辑均在 MoonBit。
    • examples:合成邮件、规则、夹具和可运行嵌入示例。

    #原创性、依赖、许可和变更

    本项目为基于标准文本的原创实现,不是 MoonWire 更名,不复用其帧格式代码,也没有移植其他 Sieve 解释器源码。运行依赖仅为 MoonBit 标准 core(包括 JSON);CLI 使用 Node.js 内置模块,不含 npm 运行依赖。所验证的 core 使用 Apache-2.0,另有其 NOTICE 中的第三方声明;本仓库不复制 core 源码,分发链接后的产物时应保留配套 core 的 LICENSE/NOTICE。本项目源码 Copyright 2026 Han Wentao,使用 Apache-2.0

    语义参考:RFC 5228RFC 5229RFC 3894RFC 5233RFC 5231。参考标准不意味着复制其示例或承诺全量一致性。未引入第三方 Sieve 实现代码。

    相邻生态如 MoonMIME、mbt-email、moonmail 提供 MIME/邮件收发能力;本库接收它们或其他宿主产生的归一化数据,职责是规则解释和上线前验证,不替代这些邮件库。目前没有捆绑或声称已完成这些包的适配器集成。

    开发使用 AI 辅助进行设计、实现、测试、排错和文档整理;以上验证由实际工具执行,未完成第三方安全审计。维护和发布账号为 Han-Wentao,使用者仍应以自己的规则夹具验证站点语义。

    0.1.0:首个有界离线版本,交付语言前端、列明的扩展、事务式动作计划、开发工具、夹具回放/差异和 CLI;后续优先根据真实兼容性用例修复,不承诺未实现的服务器功能。

    SieveError

    pub(all) suberror SieveError {
    SieveError(Diagnostic)
    } derive(
    Debug
    )

    Action

    pub(all) enum Action {
    Keep
    Discard
    FileInto(String)
    Redirect(String)
    } derive(Eq,
    Debug
    )

    Argument

    pub(all) enum Argument {
    Flag(String)
    Literal(String)
    Strings(Array[String])
    Quantity(Int)
    } derive(Eq,
    Debug
    )

    CaseResult

    pub(all) struct CaseResult {
    id : String
    plan : Plan?
    error : Diagnostic?
    expectation_met : Bool?
    } derive(Eq,
    Debug
    )

    Comparator

    pub(all) enum Comparator {
    Octet
    AsciiCasemap
    } derive(Eq,
    Debug
    )

    Conditional

    pub(all) struct Conditional {
    condition : Test
    body : Array[Statement]
    } derive(Eq,
    Debug
    )

    Diagnostic

    pub(all) struct Diagnostic {
    code : String
    message : String
    span : Span
    } derive(Eq,
    Debug
    )

    Diagnostic::as_json

    fn Diagnostic::as_json(self : Diagnostic) -> Json

    Diagnostic::render

    fn Diagnostic::render(self : Diagnostic, source : String, filename? : String) -> String

    Diagnostic::source_range

    fn Diagnostic::source_range(self : Diagnostic, source : String) -> SourceRange

    ExecutionOptions

    pub(all) struct ExecutionOptions {
    subaddress_separator : String
    } derive(Eq,
    Debug
    )

    ExecutionOptions::default

    Expectation

    pub(all) enum Expectation {
    Observe
    Deliver(Array[Action])
    FailWith(String)
    } derive(Eq,
    Debug
    )

    pub(all) struct Header {
    name : String
    value : String
    } derive(Eq,
    Debug
    )

    Limits

    pub(all) struct Limits {
    source_chars : Int
    tokens : Int
    string_chars : Int
    depth : Int
    steps : Int
    comparisons : Int
    actions : Int
    headers : Int
    variables : Int
    } derive(Eq,
    Debug
    )

    Limits::check

    fn Limits::check(self : Limits) -> Unit raise SieveError

    Limits::default

    fn Limits::default() -> Limits

    Mailbox

    pub(all) struct Mailbox {
    address : String
    localpart : String
    domain : String
    } derive(Eq,
    Debug
    )

    MatchType

    pub(all) enum MatchType {
    Is
    Contains
    Matches
    } derive(Eq,
    Debug
    )

    Message

    pub struct Message {
    headers : Array[Header]
    size : Int
    envelope_from : String
    envelope_to : Array[String]
    }

    Message::as_json

    fn Message::as_json(self : Message) -> Json

    Message::from_json

    fn Message::from_json(value : Json, limits? : Limits) -> Message raise SieveError

    Message::header_values

    fn Message::header_values(self : Message, name : String) -> Array[String]

    Message::new

    fn Message::new(headers : Array[Header], size : Int, envelope_from? : String, envelope_to? : Array[String], limits? : Limits) -> Message raise SieveError

    Message::octet_size

    fn Message::octet_size(self : Message) -> Int

    Plan

    pub(all) struct Plan {
    actions : Array[Action]
    trace : Array[TraceEvent]
    implicit_keep : Bool
    steps : Int
    comparisons : Int
    } derive(Eq,
    Debug
    )

    Plan::as_json

    fn Plan::as_json(self : Plan, include_trace? : Bool, redact_destinations? : Bool) -> Json

    Plan::has_delivery

    fn Plan::has_delivery(self : Plan) -> Bool

    Plan::redirect_targets

    fn Plan::redirect_targets(self : Plan) -> Array[String]

    PolicyChange

    pub(all) struct PolicyChange {
    id : String
    changed : Bool
    removed : Array[Action]
    added : Array[Action]
    delivery_lost : Bool
    new_redirect : Bool
    before_error : String?
    after_error : String?
    } derive(Eq,
    Debug
    )

    PolicyChange::as_json

    fn PolicyChange::as_json(self : PolicyChange, redact_destinations? : Bool) -> Json

    Program

    pub struct Program {
    syntax : Script
    capabilities : Array[String]
    limits : Limits
    }

    Program::audit

    fn Program::audit(self : Program) -> ScriptAudit

    Program::canonical_source

    fn Program::canonical_source(self : Program) -> String

    Program::replay

    fn Program::replay(self : Program, cases : Array[ReplayCase], limits? : ReplayLimits, options? : ExecutionOptions) -> ReplayReport raise SieveError

    Program::required_capabilities

    fn Program::required_capabilities(self : Program) -> Array[String]

    Program::run

    fn Program::run(self : Program, message : Message, options? : ExecutionOptions) -> Plan raise SieveError

    ReplayCase

    pub(all) struct ReplayCase {
    id : String
    message : Message
    expectation : Expectation
    }

    ReplayLimits

    pub(all) struct ReplayLimits {
    cases : Int
    total_steps : Int
    total_comparisons : Int
    } derive(Eq,
    Debug
    )

    ReplayLimits::default

    fn ReplayLimits::default() -> ReplayLimits

    ReplayReport

    pub(all) struct ReplayReport {
    results : Array[CaseResult]
    failed_expectations : Int
    execution_errors : Int
    charged_steps : Int
    charged_comparisons : Int
    } derive(Eq,
    Debug
    )

    ReplayReport::as_json

    fn ReplayReport::as_json(self : ReplayReport, redact_destinations? : Bool) -> Json

    Script

    pub(all) struct Script {
    statements : Array[Statement]
    } derive(Eq,
    Debug
    )

    ScriptAudit

    pub(all) struct ScriptAudit {
    warnings : Array[Diagnostic]
    commands : Int
    predicates : Int
    max_depth : Int
    literal_redirects : Array[String]
    literal_mailboxes : Array[String]
    } derive(Eq,
    Debug
    )

    ScriptAudit::as_json

    fn ScriptAudit::as_json(self : ScriptAudit) -> Json

    SourceLocation

    pub(all) struct SourceLocation {
    scalar_offset : Int
    utf16_offset : Int
    line : Int
    column : Int
    utf16_column : Int
    } derive(Eq,
    Debug
    )

    SourceRange

    pub(all) struct SourceRange {
    start : SourceLocation
    end : SourceLocation
    } derive(Eq,
    Debug
    )

    Span

    pub(all) struct Span {
    start : Int
    end : Int
    line : Int
    column : Int
    } derive(Eq,
    Debug
    )

    Statement

    pub(all) enum Statement {
    Command(String, Array[Argument], Span)
    Branch(Array[Conditional], Array[Statement], Span)
    } derive(Eq,
    Debug
    )

    Test

    pub(all) enum Test {
    Call(String, Array[Argument], Span)
    Not(Test, Span)
    AnyOf(Array[Test], Span)
    AllOf(Array[Test], Span)
    } derive(Eq,
    Debug
    )

    Token

    pub(all) struct Token {
    kind : TokenKind
    span : Span
    } derive(Eq,
    Debug
    )

    TokenKind

    pub(all) enum TokenKind {
    Word(String)
    Tag(String)
    Text(String)
    Number(Int)
    Symbol(Char)
    End
    } derive(Eq,
    Debug
    )

    TraceEvent

    pub(all) struct TraceEvent {
    kind : String
    label : String
    span : Span
    matched : Bool?
    } derive(Eq,
    Debug
    )

    compare_replays

    fn compare_replays(before : ReplayReport, after : ReplayReport) -> Array[PolicyChange] raise SieveError

    compile

    fn compile(source : String, limits? : Limits) -> Program raise SieveError

    diagnostic_hint

    fn diagnostic_hint(code : String) -> String

    extract_mailboxes

    fn extract_mailboxes(value : String, limits? : Limits) -> Array[Mailbox] raise SieveError

    format_script

    fn format_script(source : String, limits? : Limits) -> String raise SieveError

    handle_request

    fn handle_request(text : String) -> (Int, String)

    locate_source

    fn locate_source(source : String, scalar_offset : Int) -> SourceLocation

    match_text

    fn match_text(value : String, key : String, mode : MatchType, comparator? : Comparator, limits? : Limits) -> Bool raise SieveError

    parse

    fn parse(source : String, limits? : Limits) -> Script raise SieveError

    parse_mailbox

    fn parse_mailbox(value : String) -> Mailbox?

    parse_message_json

    fn parse_message_json(text : String, limits? : Limits) -> Message raise SieveError

    parse_replay_json

    fn parse_replay_json(text : String, limits? : Limits) -> Array[ReplayCase] raise SieveError

    run_json

    fn run_json(source : String, message_json : String, limits? : Limits, options? : ExecutionOptions) -> Json raise SieveError

    split_subaddress

    fn split_subaddress(mailbox : Mailbox, separator? : String) -> (String, String?) raise SieveError

    supported_capabilities

    fn supported_capabilities() -> Array[String]

    test_expression

    fn test_expression(source : String, message : Message, capabilities? : Array[String], limits? : Limits, options? : ExecutionOptions) -> (Bool, Array[TraceEvent]) raise SieveError

    tokenize

    fn tokenize(source : String, limits? : Limits) -> Array[Token] raise SieveError