Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions shortcuts/mail/draft/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)

// mailboxPath joins mailboxID and the given segments under the
// /open-apis/mail/v1/user_mailboxes/ root, URL-escaping each component.
// Empty segments are skipped.
func mailboxPath(mailboxID string, segments ...string) string {
parts := make([]string, 0, len(segments)+1)
parts = append(parts, url.PathEscape(mailboxID))
Expand All @@ -23,6 +26,10 @@ func mailboxPath(mailboxID string, segments ...string) string {
return "/open-apis/mail/v1/user_mailboxes/" + strings.Join(parts, "/")
}

// GetRaw fetches the raw EML of a draft via drafts.get(format=raw) and
// returns the draft ID alongside the EML. If the backend response omits
// draft_id, the input draftID is echoed back so callers always have a
// non-empty identifier to round-trip.
func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw, error) {
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "drafts", draftID), map[string]interface{}{"format": "raw"}, nil)
if err != nil {
Expand All @@ -42,6 +49,11 @@ func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw
}, nil
}

// CreateWithRaw creates a draft in mailboxID from a pre-built base64url-encoded
// EML payload and returns the server-assigned draft ID along with the
// optional preview reference URL. Use this when the caller has already
// assembled the EML with emlbuilder; for high-level compose paths use the
// MailDraftCreate shortcut instead.
func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (DraftResult, error) {
data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts"), nil, map[string]interface{}{"raw": rawEML})
if err != nil {
Expand All @@ -57,6 +69,12 @@ func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (Dr
}, nil
}

// UpdateWithRaw overwrites an existing draft's content with a pre-built
// base64url-encoded EML. Existing headers / body / attachments in the draft
// are replaced wholesale; callers that want to patch individual parts should
// use draftpkg.Apply on a parsed snapshot instead. The returned DraftResult
// carries the (possibly re-issued) draft ID and the preview reference URL
// when the backend provides one.
func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML string) (DraftResult, error) {
data, err := runtime.CallAPI("PUT", mailboxPath(mailboxID, "drafts", draftID), nil, map[string]interface{}{"raw": rawEML})
if err != nil {
Expand All @@ -72,6 +90,10 @@ func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML st
}, nil
}

// Send dispatches a previously created draft. When sendTime is a non-empty
// Unix-seconds string the backend schedules delivery; otherwise delivery is
// immediate. The returned map is the raw API response body, typically
// including message_id / thread_id / recall_status.
func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (map[string]interface{}, error) {
var bodyParams map[string]interface{}
if sendTime != "" {
Expand All @@ -80,6 +102,9 @@ func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams)
}

// extractDraftID returns the first non-empty draft identifier found in the
// API response. Looks at draft_id / id at the top level, then recurses into a
// nested "draft" object. Returns "" when no identifier is present.
func extractDraftID(data map[string]interface{}) string {
if id, ok := data["draft_id"].(string); ok && strings.TrimSpace(id) != "" {
return strings.TrimSpace(id)
Expand All @@ -93,6 +118,9 @@ func extractDraftID(data map[string]interface{}) string {
return ""
}

// extractRawEML returns the base64url-encoded raw EML from the response,
// looking at top-level "raw", a nested "message.raw", or a nested "draft"
// object. Returns "" when no EML is present.
func extractRawEML(data map[string]interface{}) string {
if raw, ok := data["raw"].(string); ok && strings.TrimSpace(raw) != "" {
return strings.TrimSpace(raw)
Expand All @@ -108,6 +136,9 @@ func extractRawEML(data map[string]interface{}) string {
return ""
}

// extractReference returns the optional preview "reference" URL from the
// response, recursing into a nested "draft" object when present. Returns ""
// when no reference is present.
func extractReference(data map[string]interface{}) string {
if data == nil {
return ""
Expand Down
97 changes: 77 additions & 20 deletions shortcuts/mail/emlbuilder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,26 +73,28 @@ func readFile(fio fileio.FileIO, path string) ([]byte, error) {
// All setter methods return a copy of the Builder (immutable/fluent style),
// so a base builder can be reused across multiple goroutines safely.
type Builder struct {
fio fileio.FileIO // injected via WithFileIO; must be set before AddFile* calls
from mail.Address
to []mail.Address
cc []mail.Address
bcc []mail.Address
replyTo []mail.Address
subject string
date time.Time
messageID string
inReplyTo string // raw value, without angle brackets
references string // space-separated list of message IDs, with angle brackets
lmsReplyToMessageID string // Lark internal message_id of the original message
textBody []byte
htmlBody []byte
calendarBody []byte
attachments []attachment
inlines []inline
extraHeaders [][2]string // ordered list of [name, value] pairs
allowNoRecipients bool // when true, Build() skips the recipient check (for drafts)
err error
fio fileio.FileIO // injected via WithFileIO; must be set before AddFile* calls
from mail.Address
to []mail.Address
cc []mail.Address
bcc []mail.Address
replyTo []mail.Address
dispositionNotificationTo []mail.Address
subject string
date time.Time
messageID string
inReplyTo string // raw value, without angle brackets
references string // space-separated list of message IDs, with angle brackets
lmsReplyToMessageID string // Lark internal message_id of the original message
textBody []byte
htmlBody []byte
calendarBody []byte
attachments []attachment
inlines []inline
extraHeaders [][2]string // ordered list of [name, value] pairs
allowNoRecipients bool // when true, Build() skips the recipient check (for drafts)
isReadReceiptMail bool // when true, Build() writes X-Lark-Read-Receipt-Mail: 1
err error
}

// WithFileIO returns a copy of b with the given FileIO.
Expand All @@ -101,6 +103,9 @@ func (b Builder) WithFileIO(fio fileio.FileIO) Builder {
return b
}

// attachment is a regular (non-inline) MIME attachment — bytes plus MIME
// metadata — accumulated on the Builder and serialized under the
// multipart/mixed outer envelope.
type attachment struct {
content []byte
contentType string
Expand Down Expand Up @@ -290,6 +295,36 @@ func (b Builder) ReplyTo(name, addr string) Builder {
return cp
}

// DispositionNotificationTo appends an address to the Disposition-Notification-To header,
// which requests a Message Disposition Notification (MDN, read receipt) from the recipient's
// mail user agent (RFC 3798). name may be empty.
//
// Recipients' clients are not obliged to honour this header; user agents commonly prompt
// the recipient, and many silently ignore it.
func (b Builder) DispositionNotificationTo(name, addr string) Builder {
if addr == "" {
return b
}
if b.err != nil {
return b
}
if err := validateDisplayName(name); err != nil {
b.err = err
return b
}
// addr ends up inside mail.Address.String() and written unescaped into
// the Disposition-Notification-To header; validate it the same way as
// other header value inputs to prevent CR/LF header injection and
// visual-spoofing via Bidi / zero-width code points.
if err := validateHeaderValue(addr); err != nil {
b.err = err
return b
}
cp := b.copySlices()
cp.dispositionNotificationTo = append(cp.dispositionNotificationTo, mail.Address{Name: name, Address: addr})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return cp
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Subject sets the Subject header.
// Non-ASCII characters are automatically RFC 2047 B-encoded.
// Returns an error builder if subject contains CR or LF.
Expand Down Expand Up @@ -567,6 +602,21 @@ func (b Builder) AllowNoRecipients() Builder {
return b
}

// IsReadReceiptMail marks this message as a read-receipt response.
// When true, Build() writes the private header "X-Lark-Read-Receipt-Mail: 1",
// which data-access extracts into MailBodyExtra.IsReadReceiptMail on draft
// creation so the subsequent DraftSend applies the READ_RECEIPT_SENT label.
//
// The header is a Lark-internal signal; smtp-out-mail-out is expected to
// strip X-Lark-* private headers before external delivery.
func (b Builder) IsReadReceiptMail(v bool) Builder {
if b.err != nil {
return b
}
b.isReadReceiptMail = v
return b
}

// Header appends an extra header to the message.
// Multiple calls with the same name result in multiple header lines.
// Returns an error builder if name or value contains CR, LF, or (for names) ':'.
Expand Down Expand Up @@ -659,6 +709,12 @@ func (b Builder) Build() ([]byte, error) {
if len(b.replyTo) > 0 {
writeHeader(&buf, "Reply-To", joinAddresses(b.replyTo))
}
if len(b.dispositionNotificationTo) > 0 {
writeHeader(&buf, "Disposition-Notification-To", joinAddresses(b.dispositionNotificationTo))
}
if b.isReadReceiptMail {
writeHeader(&buf, "X-Lark-Read-Receipt-Mail", "1")
}
if b.inReplyTo != "" {
writeHeader(&buf, "In-Reply-To", "<"+b.inReplyTo+">")
if b.lmsReplyToMessageID != "" {
Expand Down Expand Up @@ -720,6 +776,7 @@ func (b Builder) copySlices() Builder {
cp.cc = append([]mail.Address{}, b.cc...)
cp.bcc = append([]mail.Address{}, b.bcc...)
cp.replyTo = append([]mail.Address{}, b.replyTo...)
cp.dispositionNotificationTo = append([]mail.Address{}, b.dispositionNotificationTo...)
cp.attachments = append([]attachment{}, b.attachments...)
cp.inlines = append([]inline{}, b.inlines...)
cp.extraHeaders = append([][2]string{}, b.extraHeaders...)
Expand Down
Loading
Loading