Files
gowebmail/internal/graph/graph.go
2026-03-15 19:33:51 +00:00

398 lines
11 KiB
Go

// Package graph provides Microsoft Graph API mail access for personal
// outlook.com accounts. Personal accounts cannot use IMAP OAuth with
// custom Azure app registrations (Microsoft only issues opaque v1 tokens),
// so we use the Graph REST API instead with the JWT access token.
package graph
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/models"
)
const baseURL = "https://graph.microsoft.com/v1.0/me"
// Client wraps Graph API calls for a single account.
type Client struct {
token string
account *models.EmailAccount
http *http.Client
}
// New creates a Graph client for the given account.
func New(account *models.EmailAccount) *Client {
return &Client{
token: account.AccessToken,
account: account,
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) get(ctx context.Context, path string, out interface{}) error {
fullURL := path
if !strings.HasPrefix(path, "https://") {
fullURL = baseURL + path
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("graph API %s returned %d: %s", path, resp.StatusCode, string(body))
}
return json.NewDecoder(resp.Body).Decode(out)
}
func (c *Client) patch(ctx context.Context, path string, body map[string]interface{}) error {
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, baseURL+path,
strings.NewReader(string(b)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("graph PATCH %s returned %d", path, resp.StatusCode)
}
return nil
}
func (c *Client) deleteReq(ctx context.Context, path string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, baseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("graph DELETE %s returned %d", path, resp.StatusCode)
}
return nil
}
// ---- Folders ----
// GraphFolder represents a mail folder from Graph API.
type GraphFolder struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
TotalCount int `json:"totalItemCount"`
UnreadCount int `json:"unreadItemCount"`
WellKnown string `json:"wellKnownName"`
}
type foldersResp struct {
Value []GraphFolder `json:"value"`
NextLink string `json:"@odata.nextLink"`
}
// ListFolders returns all mail folders for the account.
func (c *Client) ListFolders(ctx context.Context) ([]GraphFolder, error) {
var all []GraphFolder
path := "/mailFolders?$top=100&$select=id,displayName,totalItemCount,unreadItemCount"
for path != "" {
var resp foldersResp
if err := c.get(ctx, path, &resp); err != nil {
return nil, err
}
all = append(all, resp.Value...)
if resp.NextLink != "" {
path = resp.NextLink
} else {
path = ""
}
}
return all, nil
}
// ---- Messages ----
// EmailAddress wraps a Graph email address object.
type EmailAddress struct {
EmailAddress struct {
Name string `json:"name"`
Address string `json:"address"`
} `json:"emailAddress"`
}
// GraphMessage represents a mail message from Graph API.
type GraphMessage struct {
ID string `json:"id"`
Subject string `json:"subject"`
IsRead bool `json:"isRead"`
Flag struct{ Status string `json:"flagStatus"` } `json:"flag"`
ReceivedDateTime time.Time `json:"receivedDateTime"`
HasAttachments bool `json:"hasAttachments"`
From *EmailAddress `json:"from"`
ToRecipients []EmailAddress `json:"toRecipients"`
CcRecipients []EmailAddress `json:"ccRecipients"`
Body struct {
Content string `json:"content"`
ContentType string `json:"contentType"`
} `json:"body"`
InternetMessageID string `json:"internetMessageId"`
}
// IsFlagged returns true if the message is flagged.
func (m *GraphMessage) IsFlagged() bool {
return m.Flag.Status == "flagged"
}
// FromName returns the sender display name.
func (m *GraphMessage) FromName() string {
if m.From == nil {
return ""
}
return m.From.EmailAddress.Name
}
// FromEmail returns the sender email address.
func (m *GraphMessage) FromEmail() string {
if m.From == nil {
return ""
}
return m.From.EmailAddress.Address
}
// ToList returns a comma-separated list of recipients.
func (m *GraphMessage) ToList() string {
var parts []string
for _, r := range m.ToRecipients {
parts = append(parts, r.EmailAddress.Address)
}
return strings.Join(parts, ", ")
}
type messagesResp struct {
Value []GraphMessage `json:"value"`
NextLink string `json:"@odata.nextLink"`
}
// ListMessages returns messages in a folder, optionally filtered by received date.
func (c *Client) ListMessages(ctx context.Context, folderID string, since time.Time, maxResults int) ([]GraphMessage, error) {
filter := ""
if !since.IsZero() {
filter = "&$filter=receivedDateTime+gt+" +
url.QueryEscape(since.UTC().Format("2006-01-02T15:04:05Z"))
}
top := 50
if maxResults > 0 && maxResults < top {
top = maxResults
}
path := fmt.Sprintf("/mailFolders/%s/messages?$top=%d&$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,internetMessageId%s&$orderby=receivedDateTime desc",
folderID, top, filter)
var all []GraphMessage
for path != "" {
var resp messagesResp
if err := c.get(ctx, path, &resp); err != nil {
return nil, err
}
all = append(all, resp.Value...)
if resp.NextLink != "" && (maxResults <= 0 || len(all) < maxResults) {
path = resp.NextLink
} else {
path = ""
}
}
return all, nil
}
// GetMessage returns a single message with full body.
func (c *Client) GetMessage(ctx context.Context, msgID string) (*GraphMessage, error) {
var msg GraphMessage
err := c.get(ctx, "/messages/"+msgID+
"?$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,ccRecipients,body,internetMessageId",
&msg)
return &msg, err
}
// GetMessageRaw returns the raw RFC 822 message bytes.
func (c *Client) GetMessageRaw(ctx context.Context, msgID string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
baseURL+"/messages/"+msgID+"/$value", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("graph raw message returned %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// MarkRead sets the isRead flag on a message.
func (c *Client) MarkRead(ctx context.Context, msgID string, read bool) error {
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{"isRead": read})
}
// MarkFlagged sets or clears the flag on a message.
func (c *Client) MarkFlagged(ctx context.Context, msgID string, flagged bool) error {
status := "notFlagged"
if flagged {
status = "flagged"
}
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{
"flag": map[string]string{"flagStatus": status},
})
}
// DeleteMessage moves a message to Deleted Items (soft delete).
func (c *Client) DeleteMessage(ctx context.Context, msgID string) error {
return c.deleteReq(ctx, "/messages/"+msgID)
}
// MoveMessage moves a message to a different folder.
func (c *Client) MoveMessage(ctx context.Context, msgID, destFolderID string) error {
b, _ := json.Marshal(map[string]string{"destinationId": destFolderID})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/messages/"+msgID+"/move", strings.NewReader(string(b)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("graph move returned %d", resp.StatusCode)
}
return nil
}
// InferFolderType maps Graph folder names/display names to GoWebMail folder types.
// WellKnown field is not selectable via $select — we infer from displayName instead.
func InferFolderType(displayName string) string {
switch strings.ToLower(displayName) {
case "inbox":
return "inbox"
case "sent items", "sent":
return "sent"
case "drafts":
return "drafts"
case "deleted items", "trash", "bin":
return "trash"
case "junk email", "spam", "junk":
return "spam"
case "archive":
return "archive"
default:
return "custom"
}
}
// WellKnownToFolderType kept for compatibility.
func WellKnownToFolderType(wk string) string {
return InferFolderType(wk)
}
// ---- Send mail ----
// SendMail sends an email via Graph API POST /me/sendMail.
func (c *Client) SendMail(ctx context.Context, req *models.ComposeRequest) error {
contentType := "HTML"
body := req.BodyHTML
if body == "" {
contentType = "Text"
body = req.BodyText
}
msg := map[string]interface{}{
"subject": req.Subject,
"body": map[string]string{
"contentType": contentType,
"content": body,
},
"toRecipients": graphRecipients(req.To),
"ccRecipients": graphRecipients(req.CC),
"bccRecipients": graphRecipients(req.BCC),
}
if len(req.Attachments) > 0 {
var atts []map[string]interface{}
for _, a := range req.Attachments {
atts = append(atts, map[string]interface{}{
"@odata.type": "#microsoft.graph.fileAttachment",
"name": a.Filename,
"contentType": a.ContentType,
"contentBytes": base64.StdEncoding.EncodeToString(a.Data),
})
}
msg["attachments"] = atts
}
payload, err := json.Marshal(map[string]interface{}{
"message": msg,
"saveToSentItems": true,
})
if err != nil {
return fmt.Errorf("marshal sendMail: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/sendMail", strings.NewReader(string(payload)))
if err != nil {
return fmt.Errorf("build sendMail request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+c.token)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
return fmt.Errorf("sendMail request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
errBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("sendMail returned %d: %s", resp.StatusCode, string(errBody))
}
return nil
}
func graphRecipients(addrs []string) []map[string]interface{} {
result := []map[string]interface{}{}
for _, a := range addrs {
a = strings.TrimSpace(a)
if a != "" {
result = append(result, map[string]interface{}{
"emailAddress": map[string]string{"address": a},
})
}
}
return result
}