iyi

Standard library, 0.18.0

std/mail

Internet mail messages (RFC 5322) with MIME (RFC 2045 to 2047, RFC 2231): a message built from its parts, and a message read back into its headers, its tree of parts and their decoded bodies, written in iyi. Nothing here reaches the network, the clock or the disk: std/smtp sends what this builds.

import std/mail to use it. Source: src/std/mail.iyi at v0.18.0.

  import std/mail::{Mail, MailError}
  msg = Mail::Message.new
  msg.from = Mail::Address.new("[email protected]", "Jörg Müller")
  msg.to << Mail::Address.new("[email protected]")
  msg.subject = "Grüße aus Köln"
  msg.date = Mail.date(1792224000_i64, 120)
  msg.message_id = "<[email protected]>"
  msg.text = "Hallo Ann,\n..."
  msg.html = "<p>Hallo Ann,</p>"
  msg.attach("bericht.pdf", pdf_bytes)
  wire = msg.to_s
  part = Mail.parse(wire).or_panic
  part.header("Subject")                  # "Grüße aus Köln"
  part.addresses("From").or_panic[0].name # "Jörg Müller"
  part.find("text/plain").not_nil         # the text, decoded

**Building.** Mail::Message takes From, To, Cc, Bcc, Reply-To, Subject, the Date and the Message-ID from the caller - Mail.date writes a Date from Unix seconds and an offset, and no identifier is made up here, since both would need the clock or a source of randomness - and a text body, an HTML body, and attachments. Text alone is one text/plain part; text and HTML are multipart/alternative, plain first as RFC 2046 section 5.1.4 orders them; attachments put that body and themselves in a multipart/mixed. A text part's line breaks are written as CRLF, the canonical form of RFC 2046 section 4.1.1, whichever of CRLF, CR or LF the caller used; it goes as 7bit when it is ASCII in lines of 78 or fewer, and otherwise as whichever of quoted-printable and base64 is the shorter, as Python's email chooses. Attachments go as base64. Bcc is not written into the message: its addresses are among recipients, where std/smtp sends to them, and no other recipient learns them.

A boundary is iyi- and 32 hex digits of the SHA-1 of the parts it separates, made again from itself while a part holds it - as std/http's MultipartBuilder makes one - so it cannot be in a part by construction, the same message builds to the same bytes, and std/mail reaches no source of randomness for it.

**Headers** are folded at whitespace so that every line is 78 columns or fewer (RFC 5322 section 2.1.1), save an address longer than that, which has nowhere to fold. A Subject or other unstructured value that is not printable ASCII in words of single spaces - non-ASCII text, a tab, space at either end or two together, a word too long for a line, or =?, which a reader would take for the start of an encoded-word - goes as RFC 2047 encoded-words: UTF-8, each word 75 characters or fewer and holding whole characters, in Q or B, whichever is the shorter. A display name of atoms goes as it is, one with other printable ASCII as a quoted string, and any other as encoded-words - one word when it fits one, and its atoms between words when it does not, since Python's email reads a space into the gap between two encoded-words of a name, where RFC 2047 section 6.2 drops it (its own long names come back so); an attachment's file name that is not short printable ASCII goes as RFC 2231 filename*, in continuations when it is long. A header value or a display name that holds a line break is a panic, as it would be a header of the caller's making.

**Reading.** Mail.parse reads a message whose lines end in CRLF or LF into a Mail::Part: its fields unfolded, in order, and its body; a multipart's parts, and a message/rfc822's message, are parts of their own. header answers a field with its encoded-words decoded; body the bytes the transfer encoding stands for; text those bytes in UTF-8 from the part's charset - UTF-8, US-ASCII, ISO-8859-1, ISO-8859-15 and windows-1252, under their usual names - and an error that names any other. addresses reads an address list: RFC 5322's mailboxes and groups (whose members it answers, the group's name set aside), quoted local parts, domain literals, and RFC 6532's UTF-8. Comments are passed over wherever whitespace may be, and are never taken for a name: the old addr (Name) form is an address without one. A route in an angle address (obs-route) is passed over too.

Where RFC 2045 asks a reader to be forgiving, it is: a base64 body's characters outside the alphabet are passed over and it ends at its padding (section 6.8), and an = in quoted-printable that starts no escape is kept (section 6.7, note 2); an encoded-word that does not decode is left as it was written (RFC 2047 section 6.3), and a Content-Type that cannot be read is text/plain (RFC 2045 section 5.2). What is refused, as a MailError, is what leaves the structure in doubt: a header line that is not a field, a header section that begins with a folded line, a multipart that names no boundary, has no boundary line or ends before its closing one, parts nested deeper than 32, and an address list that is not one. A body in a transfer encoding that is not one of RFC 2045's is an error when it is asked for, and so is text not in its charset.

Quoted-printable is written as Python's email.quoprimime.body_encode writes it, line for line, and read as RFC 2045 says; base64 in lines of 76. bench/std_mail_exercise.sh has Python's email read what this builds and this read what Python's EmailMessage builds, under policy.default and policy.SMTP, holds the encodings to quopri, base64 and email.quoprimime, and dates to email.utils.

Not here: S/MIME and PGP, DKIM, format=flowed, charsets beyond the five, and writing 8-bit bodies: everything this writes is 7-bit but for an RFC 6532 address, which Message#utf8? names for SMTPUTF8.

Mailstruct

std/mailiyi doc src/std/mail.iyi
pub struct Mail
  # A mailbox: an address and the name shown with it.
  class Address
    def ==(other : Address) : Bool
    # The addr-spec: the local part quoted where it is not a dot-atom.
    def address : String
    def domain : String
    # What `parse` makes, already read.
    def initialize(local : String, domain : String, name : String, read : Bool)
    # *address* is an addr-spec: `[email protected]`, or
    # `"john doe"@example.com` with its local part quoted. Anything else
    # is a panic, as is a name with a line break in it.
    def initialize(address : String, name : String = "")
    # The local part, unquoted, and the domain as written.
    def local : String
    # The display name, decoded; "" when there is none.
    def name : String
    # The one mailbox *text* holds.
    def self.parse(text : String) : Address | MailError
    # The mailboxes of an address list, a group's members in its place.
    def self.parse_list(text : String) : Array(Address) | MailError
    # As a person reads it: `Name <[email protected]>`, the name quoted
    # when it is not atoms, nothing encoded.
    def to_s : String
    # Whether the address is more than ASCII (RFC 6532), which only a
    # server that offers SMTPUTF8 takes.
    def utf8? : Bool
  end
  # A message to be written: its addresses, subject, date and identifier,
  # its text and HTML, and its attachments.
  class Message
    protected def self.addresses(name : String, list : Array(Address)) : String
    # A file sent with the message, under *filename*, as *content_type*:
    # the type its extension names (std/mime) when nil.
    def attach(filename : String, data : String, content_type : String | ::Nil = nil) : self
    protected def self.attachment(filename : String, kind : String, data : String) : MailEntity
    def bcc : Array(Address)
    def cc : Array(Address)
    def date : String | ::Nil
    # An RFC 5322 date-time - `Mail.date` writes one - refused when it is
    # not one.
    def date=(value : String) : String
    # `filename="..."` for a short name of printable ASCII; RFC 2231's
    # `filename*=utf-8''...` for any other, in continuations of whole
    # characters when it is longer - 50 encoded bytes in the first, after
    # its charset, and 60 in the rest, so each fits a line. All but the
    # last end in `;`.
    protected def self.filename_params(filename : String) : Array(String)
    def from : Address | ::Nil
    def from=(value : Address | ::Nil) : Address | ::Nil
    # A header of the caller's own, written as unstructured text. The
    # headers the message writes itself are refused here.
    def header(name : String, value : String) : self
    def html : String | ::Nil
    def html=(value : String) : String
    def initialize
    def message_id : String | ::Nil
    # `<left@right>`, the brackets added when they are not there.
    def message_id=(value : String) : String
    # The parts under one boundary, made from their own hash.
    protected def self.multipart(subtype : String, children : Array(MailEntity)) : MailEntity
    # Every address the message goes to - To, Cc and Bcc - once each.
    def recipients : Array(String)
    def reply_to : Array(Address)
    # ASCII without NUL, in lines of 78 or fewer.
    protected def self.seven_bit?(text : String) : Bool
    def subject : String | ::Nil
    def subject=(value : String) : String
    def text : String | ::Nil
    def text=(value : String) : String
    protected def self.text_part(subtype : String, text : String) : MailEntity
    def to : Array(Address)
    # The message, CRLF line breaks and all.
    def to_s : String
    # Whether an address in the message is RFC 6532's UTF-8.
    def utf8? : Bool
  end
  # A message or one of its parts, as read: its header fields unfolded and
  # in order, and its body - a multipart's parts, a `message/rfc822`'s
  # message, or bytes in a transfer encoding.
  class Part
    # The mailboxes of every field named *name* - `From`, `To`, `Cc` -
    # none when there is no such field.
    def addresses(name : String) : Array(Address) | MailError
    def attachment? : Bool
    # The parts whose disposition is `attachment`, in order.
    def attachments : Array(Part)
    # The bytes the body stands for, its transfer encoding undone.
    def body : String | MailError
    # The charset text is in: `us-ascii` when none is named (RFC 2045
    # section 5.2), in lower case.
    def charset : String
    # The disposition in lower case - `attachment`, `inline` - or nil.
    def disposition : String | ::Nil
    # The file name of Content-Disposition, or else Content-Type's `name`.
    def filename : String | ::Nil
    # The first part of *media_type* that is not an attachment, depth
    # first: `find("text/plain")` is the text a reader shows.
    def find(media_type : String) : Part | ::Nil
    protected def self.first(headers : Array(::Tuple(String, String)), lower : String) : String | ::Nil
    # The first field named *name*, any case, its encoded-words decoded.
    def header(name : String) : String | ::Nil
    # Every field named *name*, in order, decoded.
    def header_values(name : String) : Array(String)
    # Each field's name as written and its value unfolded, whitespace at
    # either end dropped, nothing decoded.
    def headers : Array(::Tuple(String, String))
    def initialize(source : String, headers : Array(::Tuple(String, String)), media_type : String, params : Hash(String, String), body_at : Int32, body_end : Int32, parts : Array(Part))
    # `type/subtype` in lower case: `text/plain` when the part names none,
    # or names one that cannot be read (RFC 2045 section 5.2).
    def media_type : String
    def multipart? : Bool
    # A Content-Type parameter, RFC 2231's decoded.
    def param(name : String) : String | ::Nil
    def self.parse(text : String) : Part | MailError
    # The parts of a multipart, or the message a `message/rfc822` holds.
    def parts : Array(Part)
    protected def self.prefix?(a : Pointer(UInt8), b : Pointer(UInt8), size : Int32) : Bool
    # The first field named *name*, as written.
    def raw_header(name : String) : String | ::Nil
    protected def self.read(source : String, start : Int32, stop : Int32, depth : Int32, default_type : String) : Part | MailError
    # The parts of a multipart body: between `--boundary` lines, the line
    # break before each line its own; to the `--boundary--` line. The
    # preamble and the epilogue are passed over.
    protected def self.split(source : String, start : Int32, stop : Int32, boundary : String, media : String) : Array(::Tuple(Int32, Int32)) | MailError
    def subject : String | ::Nil
    # The body as text in UTF-8, from the part's charset, each CRLF an LF.
    def text : String | MailError
    # The transfer encoding in lower case: `7bit` when none is named.
    def transfer_encoding : String
    protected def self.trim(value : String) : String
    # This part, then each under it, depth first.
    def walk(&block : (Part -> Nil)) : Nil
  end
  # RFC 5322's date-time for *unix* seconds, at *offset* minutes east of
  # UTC: `Sat, 17 Oct 2026 14:00:00 +0200`, as `email.utils.format_datetime`
  # writes it.
  def self.date(unix : Int64, offset : Int32 = 0) : String
  # What base64 *text* stands for, characters outside the alphabet passed
  # over.
  def self.decode_base64(text : String) : String
  # *text* from *charset* in UTF-8.
  def self.decode_charset(text : String, charset : String) : String | MailError
  # What quoted-printable *text* stands for, hard breaks as CRLF.
  def self.decode_quoted_printable(text : String) : String
  # *text* with its RFC 2047 encoded-words decoded.
  def self.decode_words(text : String) : String
  # *data* in base64, lines of 76 and a CRLF after each.
  def self.encode_base64(data : String) : String
  # *data* in quoted-printable, lines of 76 or fewer; its line breaks
  # hard ones written as CRLF, or with *binary* escaped like any byte.
  def self.encode_quoted_printable(data : String, binary : Bool = false) : String
  # An unstructured header line, `name: value` folded and encoded as the
  # builder writes it, CRLF and all.
  def self.fold(name : String, value : String) : String
  def initialize
  # A message read from *text*: its header section, then its body.
  def self.parse(text : String) : Part | MailError
  # A date-time (RFC 5322 section 3.3, with section 4.3's obsolete years
  # and zones) as Unix seconds and the offset it was written at, in
  # minutes. The day of the week is not held to the date, as readers do
  # not hold it. A military zone is -0000, as section 4.3 says.
  def self.parse_date(text : String) : ::Tuple(Int64, Int32) | MailError
end

MailErrorstruct

What a message, a header or an address is that it should not be, as a value: reason says what; message says it is mail's.

std/mailiyi doc src/std/mail.iyi
pub struct MailError
  def initialize(reason : String)
  def reason : String
  def to_s : String
end

Error for MailErrorimpl

std/mailiyi doc src/std/mail.iyi
impl Error for MailError
  def message : String
end