emersion / go-message

✉️ A streaming Go library for the Internet Message Format and mail messages
MIT License
368 stars 107 forks source link

Support multipart/related for inline attachments #179

Open BluePsyduck opened 1 month ago

BluePsyduck commented 1 month ago

Feature request: Support Content-Type "multipart/related" to allow inline attachments

An inline attachment (or embedded attachment) is e.g. an image attached to the mail, which is referenced and inlined directly into the HTML part of the mail. The difference to normal attachments is that an inline attachment will not be listed in the attachment list of the mail (if the client supports it).

To support inline attachments, the content-type "multipart/related" is required, which will contain the inline parts (either a single one or via multipart/alternative), as well as the embedded attachments using "content-disposition: inline".

There are two use cases which have to be accounted for:

Use case 1: Only inline attachments

When only using inline attachments, the top-level content-type is "multipart/related". The MIME message will have the following structure:

 From: <john@contoso.com>
 To: <imtiaz@contoso.com>
 Subject: Example with inline attachment.
 Date: Mon, 10 Mar 2008 14:36:46 -0700
 MIME-Version: 1.0
 Content-Type: multipart/related; boundary="simple boundary"

 --simple boundary
 Content-Type: text/html;

 ...Text with reference...

 --simple boundary
 Content-Type: image/png; name="inline.PNG"
 Content-Transfer-Encoding: base64
 Content-ID: <6583CF49B56F42FEA6A4A118F46F96FB@example.com>
 Content-Disposition: inline; filename=" inline.png"

 ...Attachment data encoded with base64...
 --simple boundary--

Source: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcmail/92ce322f-efa1-48c5-828a-543063fa9db8

Use case 2: Inline attachments and non-inline attachments

When mixing inline attachments with non-inline ones, the top-level content-type is "multipart/mixed", and one of its parts will be "multipart/related". The MIME message will have the following structure:

 From: <john@contoso.com>
 To: <imtiaz@contoso.com>
 Subject: Example with inline and non-inline attachments.
 Date: Mon, 10 Mar 2008 14:36:46 -0700
 MIME-Version: 1.0
 Content-Type: multipart/mixed; boundary="simple boundary 1"

 --simple boundary 1
 Content-Type: multipart/related; boundary="simple boundary 2"

 --simple boundary 2
 Content-Type: multipart/alternative; boundary="simple boundary 3"

 --simple boundary 3
 Content-Type: text/plain

 ...Text without inline reference...
 --simple boundary 3
 Content-Type: text/html

 ...Text with inline reference...
 --simple boundary 3--
 --simple boundary 2
 Content-Type: image/png; name="inline.PNG"
 Content-Transfer-Encoding: base64
 Content-ID: <6583CF49B56F42FEA6A4A118F46F96FB@example.com>
 Content-Disposition: inline; filename="Inline.png"

 ...Attachment data encoded with base64...
 --simple boundary 2--

 --simple boundary 1
 Content-Type: image/png; name=" Attachment "
 Content-Transfer-Encoding: base64
 Content-Disposition: attachment; filename="Attachment.png"

 ...Attachment data encoded with base64...
 --simple boundary 1--

Source: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcmail/7a08211a-760a-41af-8cab-0acf462c4094

Proposal

To cover these use cases, I propose the following changes within the mail package. (I will also attempt to implement this myself and open a pull request in the next days.)

Any comments are welcome, especially whether the naming of "RelatedWriter" is good enough for this.

Vovan-VE commented 4 weeks ago

Aren't multipart/* the same in terms of writer? ALternative implementation:

  1. Rename InlineWriter to common MultipartWriter.
  2. Extract from Writer.CreateInline() a common Writer.CreateMultipart(contentType string).
  3. Add Writer.CreateRelated() only calling Writer.CreateMultipart("multipart/related").
  4. One of:
    • missleading naming:
      1. Change Writer.CreateInline() to only call Writer.CreateMultipart("multipart/alternative").
    • obvious naming:
      1. Add Writer.CreateAlternative() only calling Writer.CreateMultipart("multipart/alternative").
      2. Change Writer.CreateInline() to only call Writer.CreateAlternative() and deprecate.
Vovan-VE commented 4 weeks ago

Need also some API to allow nesting MultipartWriter.

Vovan-VE commented 4 weeks ago

Just in case, I temporary end up with such replacement:

package mailwriter

import (
    "io"
    "mime"
    "strings"

    "github.com/emersion/go-message"
    "github.com/emersion/go-message/mail"
)

func initInlineContentTransferEncoding(h *message.Header) {
    if !h.Has("Content-Transfer-Encoding") {
        t, _, _ := h.ContentType()
        if strings.HasPrefix(t, "text/") {
            h.Set("Content-Transfer-Encoding", "quoted-printable")
        } else {
            h.Set("Content-Transfer-Encoding", "base64")
        }
    }
}

func initInlineHeader(h *mail.InlineHeader) {
    disp, _, _ := mime.ParseMediaType(h.Get("Content-Disposition"))
    if disp != "inline" {
        h.Set("Content-Disposition", "inline")
    }
    initInlineContentTransferEncoding(&h.Header)
}

func initAttachmentHeader(h *mail.AttachmentHeader) {
    disp, _, _ := h.ContentDisposition()
    if disp != "attachment" {
        h.Set("Content-Disposition", "attachment")
    }
    if !h.Has("Content-Transfer-Encoding") {
        h.Set("Content-Transfer-Encoding", "base64")
    }
}

type Writer struct {
    mw *message.Writer
}

func CreateWriter(w io.Writer, header mail.Header) (*Writer, error) {
    return createMultipart(w, "multipart/mixed", header)
}

func CreateAlternativeWriter(w io.Writer, header mail.Header) (*Writer, error) {
    return createMultipart(w, "multipart/alternative", header)
}

func CreateRelatedWriter(w io.Writer, header mail.Header) (*Writer, error) {
    return createMultipart(w, "multipart/related", header)
}

func createMultipart(w io.Writer, contentType string, header mail.Header) (*Writer, error) {
    header = header.Copy() // don't modify the caller's view
    header.Set("Content-Type", contentType)

    mw, err := message.CreateWriter(w, header.Header)
    if err != nil {
        return nil, err
    }

    return &Writer{mw}, nil
}

func CreateSingleInlineWriter(w io.Writer, header mail.Header) (io.WriteCloser, error) {
    header = header.Copy() // don't modify the caller's view
    initInlineContentTransferEncoding(&header.Header)
    return message.CreateWriter(w, header.Header)
}

func (w *Writer) CreateAlternative() (*Writer, error) {
    return w.createMultipart("multipart/alternative")
}

func (w *Writer) CreateRelated() (*Writer, error) {
    return w.createMultipart("multipart/related")
}

func (w *Writer) createMultipart(contentType string) (*Writer, error) {
    var h message.Header
    h.Set("Content-Type", contentType)

    mw, err := w.mw.CreatePart(h)
    if err != nil {
        return nil, err
    }
    return &Writer{mw}, nil
}

func (w *Writer) CreateInlinePart(h mail.InlineHeader) (io.WriteCloser, error) {
    h = mail.InlineHeader{h.Header.Copy()} // don't modify the caller's view
    initInlineHeader(&h)
    return w.mw.CreatePart(h.Header)
}

func (w *Writer) CreateAttachment(h mail.AttachmentHeader) (io.WriteCloser, error) {
    h = mail.AttachmentHeader{h.Header.Copy()} // don't modify the caller's view
    initAttachmentHeader(&h)
    return w.mw.CreatePart(h.Header)
}

func (w *Writer) Close() error {
    return w.mw.Close()
}
BluePsyduck commented 3 weeks ago

Thank you for your responses.

While the multipart writers might be the same (I didn't check the RFC for what is allowed, I find them hard to understand...), I think the writers should stay separate to make it easier to understand how the common usecases of mails should be structured. With everything in the Writer, you have no restrictions anymore where to create which parts of the mail, e.g. now you can put attachments right into the alternative parts, which will most likely not work as expected. That's why I will stay with my initial thought of having a new RelatedWriter, so the methods on the different writers dictate what is possible to add at that point.

What I agree with you is the kind-of misleading naming of InlineWriter and its corresponding functions. I will change them to AlternativeWriter (better matching the new RelatedWriter) and mark the old wordings as deprecated.