Add Nextcloud integration tools
- CLI tools: nextcloud-client, nextcloud-contacts, nextcloud-calendar, nextcloud-mail - Build script with compile-time credentials - Skills for all four tools - Email tool supports IMAP/SMTP with attachment download
This commit is contained in:
3
tools/go/nextcloud-calendar/go.mod
Normal file
3
tools/go/nextcloud-calendar/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/wltbagent/nextcloud-calendar
|
||||
|
||||
go 1.24.4
|
||||
454
tools/go/nextcloud-calendar/main.go
Normal file
454
tools/go/nextcloud-calendar/main.go
Normal file
@@ -0,0 +1,454 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Build-time configuration (set via ldflags at compile time)
|
||||
var (
|
||||
BuildServerURL string
|
||||
BuildUsername string
|
||||
BuildToken string
|
||||
)
|
||||
|
||||
// Config holds Nextcloud connection configuration
|
||||
type Config struct {
|
||||
URL string
|
||||
User string
|
||||
Token string
|
||||
CalDAV string
|
||||
}
|
||||
|
||||
// Client is a Nextcloud CalDAV client
|
||||
type Client struct {
|
||||
Config Config
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// MultiStatus represents WebDAV multistatus response
|
||||
type MultiStatus struct {
|
||||
XMLName xml.Name `xml:"multistatus"`
|
||||
Responses []Response `xml:"response"`
|
||||
}
|
||||
|
||||
// Response represents a single WebDAV response
|
||||
type Response struct {
|
||||
Href string `xml:"href"`
|
||||
PropStats []PropStat `xml:"propstat"`
|
||||
}
|
||||
|
||||
// PropStat represents a WebDAV propstat element
|
||||
type PropStat struct {
|
||||
Prop Prop `xml:"prop"`
|
||||
Status string `xml:"status"`
|
||||
}
|
||||
|
||||
// Prop represents WebDAV properties
|
||||
type Prop struct {
|
||||
DisplayName string `xml:"displayname"`
|
||||
GetETag string `xml:"getetag"`
|
||||
ResourceType ResourceType `xml:"resourcetype"`
|
||||
}
|
||||
|
||||
// ResourceType represents resourcetype element
|
||||
type ResourceType struct {
|
||||
Collection string `xml:"collection"`
|
||||
}
|
||||
|
||||
// Calendar represents a CalDAV calendar
|
||||
type Calendar struct {
|
||||
Href string
|
||||
DisplayName string
|
||||
ETag string
|
||||
}
|
||||
|
||||
// Event represents a calendar event (iCalendar format)
|
||||
type Event struct {
|
||||
UID string
|
||||
Summary string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
iCal string // Raw iCalendar content
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Parse flags (with build-time defaults)
|
||||
serverURL := flag.String("url", BuildServerURL, "Nextcloud server URL (e.g., https://cloud.example.com)")
|
||||
username := flag.String("user", BuildUsername, "Nextcloud username")
|
||||
token := flag.String("token", BuildToken, "Nextcloud app token")
|
||||
operation := flag.String("op", "list-calendars", "Operation: list-calendars, list-events, get-event, create-event, delete-event")
|
||||
calendarName := flag.String("calendar", "personal", "Calendar name (default: personal)")
|
||||
eventUID := flag.String("uid", "", "Event UID for get/delete-event operation")
|
||||
icalPath := flag.String("ical", "", "Path to iCalendar file for create-event operation")
|
||||
summary := flag.String("summary", "", "Event summary/title (for create-event)")
|
||||
startTime := flag.String("start", "", "Event start time (RFC3339 format, e.g., 2026-02-11T10:00:00Z)")
|
||||
endTime := flag.String("end", "", "Event end time (RFC3339 format, e.g., 2026-02-11T11:00:00Z)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Use environment variables as fallback if not set at build time or via flags
|
||||
if *serverURL == "" {
|
||||
*serverURL = os.Getenv("NEXTCLOUD_URL")
|
||||
}
|
||||
if *username == "" {
|
||||
*username = os.Getenv("NEXTCLOUD_USER")
|
||||
}
|
||||
if *token == "" {
|
||||
*token = os.Getenv("NEXTCLOUD_TOKEN")
|
||||
}
|
||||
|
||||
if serverURL == nil || username == nil || token == nil || *serverURL == "" || *username == "" || *token == "" {
|
||||
fmt.Println("Error: URL, user, and token are required (set via ldflags, flags, or env vars)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Build config
|
||||
encodedUsername := url.PathEscape(*username)
|
||||
config := Config{
|
||||
URL: *serverURL,
|
||||
User: *username,
|
||||
Token: *token,
|
||||
CalDAV: fmt.Sprintf("%s/remote.php/dav/calendars/%s", strings.TrimSuffix(*serverURL, "/"), encodedUsername),
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := &Client{
|
||||
Config: config,
|
||||
HTTPClient: &http.Client{},
|
||||
}
|
||||
|
||||
var err error
|
||||
switch *operation {
|
||||
case "list-calendars":
|
||||
err = client.listCalendars()
|
||||
case "list-events":
|
||||
err = client.listEvents(*calendarName)
|
||||
case "get-event":
|
||||
if *eventUID == "" {
|
||||
fmt.Println("Error: --uid is required for get-event operation")
|
||||
os.Exit(1)
|
||||
}
|
||||
err = client.getEvent(*calendarName, *eventUID)
|
||||
case "create-event":
|
||||
event := &Event{
|
||||
Summary: *summary,
|
||||
}
|
||||
if *icalPath != "" {
|
||||
icalContent, readErr := os.ReadFile(*icalPath)
|
||||
if readErr != nil {
|
||||
fmt.Printf("Error reading iCalendar file: %v\n", readErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
event.iCal = string(icalContent)
|
||||
} else {
|
||||
// Parse times if provided
|
||||
if *startTime != "" {
|
||||
startTimeParsed, parseErr := time.Parse(time.RFC3339, *startTime)
|
||||
if parseErr != nil {
|
||||
fmt.Printf("Error parsing start time: %v\n", parseErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
event.Start = startTimeParsed
|
||||
}
|
||||
if *endTime != "" {
|
||||
endTimeParsed, parseErr := time.Parse(time.RFC3339, *endTime)
|
||||
if parseErr != nil {
|
||||
fmt.Printf("Error parsing end time: %v\n", parseErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
event.End = endTimeParsed
|
||||
}
|
||||
}
|
||||
err = client.createEvent(*calendarName, event)
|
||||
case "delete-event":
|
||||
if *eventUID == "" {
|
||||
fmt.Println("Error: --uid is required for delete-event operation")
|
||||
os.Exit(1)
|
||||
}
|
||||
err = client.deleteEvent(*calendarName, *eventUID)
|
||||
default:
|
||||
fmt.Printf("Unknown operation: %s\n", *operation)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) listCalendars() error {
|
||||
// PROPFIND body
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname />
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>`)
|
||||
|
||||
req, err := http.NewRequest("PROPFIND", c.Config.CalDAV+"/", strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", "1")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 207 {
|
||||
return fmt.Errorf("unexpected status: %d (expected 207 Multi-Status)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var multiStatus MultiStatus
|
||||
decoder := xml.NewDecoder(resp.Body)
|
||||
if err := decoder.Decode(&multiStatus); err != nil {
|
||||
return fmt.Errorf("xml decode failed: %v", err)
|
||||
}
|
||||
|
||||
// Print calendars
|
||||
fmt.Println("Calendars:")
|
||||
fmt.Println("----------")
|
||||
for _, response := range multiStatus.Responses {
|
||||
relPath := strings.TrimPrefix(response.Href, c.Config.CalDAV+"/")
|
||||
if relPath == "" || !strings.HasSuffix(relPath, "/") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip system folders (inbox, outbox, trashbin)
|
||||
if relPath == "inbox/" || relPath == "outbox/" || relPath == "trashbin/" {
|
||||
continue
|
||||
}
|
||||
|
||||
var mergedProp Prop
|
||||
for _, propStat := range response.PropStats {
|
||||
if strings.Contains(propStat.Status, "200") {
|
||||
if propStat.Prop.DisplayName != "" {
|
||||
mergedProp.DisplayName = propStat.Prop.DisplayName
|
||||
}
|
||||
if propStat.Prop.GetETag != "" {
|
||||
mergedProp.GetETag = propStat.Prop.GetETag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calendarName := strings.TrimSuffix(relPath, "/")
|
||||
fmt.Printf("Name: %s\n", mergedProp.DisplayName)
|
||||
fmt.Printf("Path: %s\n", calendarName)
|
||||
fmt.Printf("ETag: %s\n", mergedProp.GetETag)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) listEvents(calendarName string) error {
|
||||
calendarPath := fmt.Sprintf("%s/%s/", c.Config.CalDAV, calendarName)
|
||||
|
||||
// PROPFIND body
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>`)
|
||||
|
||||
req, err := http.NewRequest("PROPFIND", calendarPath, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", "1")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 207 {
|
||||
return fmt.Errorf("unexpected status: %d (expected 207 Multi-Status)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var multiStatus MultiStatus
|
||||
decoder := xml.NewDecoder(resp.Body)
|
||||
if err := decoder.Decode(&multiStatus); err != nil {
|
||||
return fmt.Errorf("xml decode failed: %v", err)
|
||||
}
|
||||
|
||||
// Print events
|
||||
fmt.Printf("Events in '%s' calendar:\n", calendarName)
|
||||
fmt.Println("-----------------")
|
||||
eventCount := 0
|
||||
for _, response := range multiStatus.Responses {
|
||||
relPath := strings.TrimPrefix(response.Href, calendarPath)
|
||||
if relPath == "" || strings.HasSuffix(relPath, "/") {
|
||||
continue
|
||||
}
|
||||
|
||||
eventCount++
|
||||
var etag string
|
||||
for _, propStat := range response.PropStats {
|
||||
if strings.Contains(propStat.Status, "200") {
|
||||
etag = propStat.Prop.GetETag
|
||||
}
|
||||
}
|
||||
|
||||
// Extract UID from .ics filename
|
||||
filename := relPath
|
||||
if idx := strings.LastIndex(relPath, "/"); idx != -1 {
|
||||
filename = relPath[idx+1:]
|
||||
}
|
||||
eventUID := strings.TrimSuffix(filename, ".ics")
|
||||
fmt.Printf("UID: %s\n", eventUID)
|
||||
fmt.Printf("ETag: %s\n", etag)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if eventCount == 0 {
|
||||
fmt.Println("(No events found)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getEvent(calendarName, eventUID string) error {
|
||||
eventPath := fmt.Sprintf("%s/%s/%s.ics", c.Config.CalDAV, calendarName, eventUID)
|
||||
|
||||
req, err := http.NewRequest("GET", eventPath, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("Accept", "text/calendar; charset=utf-8")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("get event failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Event: %s\n", eventUID)
|
||||
fmt.Println("--------")
|
||||
fmt.Println(string(content))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) createEvent(calendarName string, event *Event) error {
|
||||
calendarPath := fmt.Sprintf("%s/%s/", c.Config.CalDAV, calendarName)
|
||||
|
||||
// Generate iCalendar if not provided
|
||||
icalContent := event.iCal
|
||||
if icalContent == "" {
|
||||
if event.Summary == "" {
|
||||
return fmt.Errorf("event summary is required")
|
||||
}
|
||||
|
||||
// Generate a simple VEVENT
|
||||
uid := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
now := time.Now().UTC().Format("20060102T150405Z")
|
||||
|
||||
var startTime, endTime string
|
||||
if !event.Start.IsZero() {
|
||||
startTime = event.Start.UTC().Format("20060102T150405Z")
|
||||
} else {
|
||||
// Default to now + 1 hour
|
||||
startTime = time.Now().UTC().Add(1 * time.Hour).Format("20060102T150405Z")
|
||||
}
|
||||
|
||||
if !event.End.IsZero() {
|
||||
endTime = event.End.UTC().Format("20060102T150405Z")
|
||||
} else {
|
||||
// Default to start + 1 hour
|
||||
endTime = startTime
|
||||
}
|
||||
|
||||
icalContent = fmt.Sprintf(`BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//WLTBAgent//NextCalendar//EN
|
||||
BEGIN:VEVENT
|
||||
UID:%s
|
||||
DTSTAMP:%s
|
||||
DTSTART:%s
|
||||
DTEND:%s
|
||||
SUMMARY:%s
|
||||
END:VEVENT
|
||||
END:VCALENDAR`, uid, now, startTime, endTime, event.Summary)
|
||||
}
|
||||
|
||||
eventPath := fmt.Sprintf("%s%s.ics", calendarPath, fmt.Sprintf("%d", time.Now().UnixNano()))
|
||||
|
||||
req, err := http.NewRequest("PUT", eventPath, strings.NewReader(icalContent))
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
||||
return fmt.Errorf("create event failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
fmt.Printf("Event created successfully\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) deleteEvent(calendarName, eventUID string) error {
|
||||
eventPath := fmt.Sprintf("%s/%s/%s.ics", c.Config.CalDAV, calendarName, eventUID)
|
||||
|
||||
req, err := http.NewRequest("DELETE", eventPath, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 204 {
|
||||
return fmt.Errorf("delete event failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
fmt.Printf("Event deleted: %s\n", eventUID)
|
||||
|
||||
return nil
|
||||
}
|
||||
3
tools/go/nextcloud-client/go.mod
Normal file
3
tools/go/nextcloud-client/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/wltbagent/nextcloud-client
|
||||
|
||||
go 1.21
|
||||
521
tools/go/nextcloud-client/main.go
Normal file
521
tools/go/nextcloud-client/main.go
Normal file
@@ -0,0 +1,521 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Build-time configuration (set via ldflags at compile time)
|
||||
var (
|
||||
BuildServerURL string
|
||||
BuildUsername string
|
||||
BuildToken string
|
||||
)
|
||||
|
||||
// Config holds Nextcloud connection configuration
|
||||
type Config struct {
|
||||
URL string
|
||||
User string
|
||||
Token string
|
||||
WebDAV string
|
||||
}
|
||||
|
||||
// Client is a Nextcloud WebDAV client
|
||||
type Client struct {
|
||||
Config Config
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// MultiStatus represents the WebDAV multistatus response
|
||||
type MultiStatus struct {
|
||||
XMLName xml.Name `xml:"multistatus"`
|
||||
Responses []Response `xml:"response"`
|
||||
}
|
||||
|
||||
// Response represents a single WebDAV response
|
||||
type Response struct {
|
||||
Href string `xml:"href"`
|
||||
PropStats []PropStat `xml:"propstat"`
|
||||
}
|
||||
|
||||
// PropStat represents a WebDAV propstat element
|
||||
type PropStat struct {
|
||||
Prop Prop `xml:"prop"`
|
||||
Status string `xml:"status"`
|
||||
}
|
||||
|
||||
// Prop represents WebDAV properties
|
||||
type Prop struct {
|
||||
GetLastModified string `xml:"getlastmodified"`
|
||||
GetContentLength int64 `xml:"getcontentlength"`
|
||||
GetContentType string `xml:"getcontenttype"`
|
||||
ResourceType ResourceType `xml:"resourcetype"`
|
||||
GetETag string `xml:"getetag"`
|
||||
}
|
||||
|
||||
// ResourceType represents the resourcetype element
|
||||
type ResourceType struct {
|
||||
Collection string `xml:"collection"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Parse flags (with build-time defaults)
|
||||
serverURL := flag.String("url", BuildServerURL, "Nextcloud server URL (e.g., https://cloud.example.com)")
|
||||
username := flag.String("user", BuildUsername, "Nextcloud username")
|
||||
token := flag.String("token", BuildToken, "Nextcloud app token")
|
||||
operation := flag.String("op", "list", "Operation: list, upload, download, mkdir, delete, move, copy, info")
|
||||
path := flag.String("path", "/", "Remote path")
|
||||
localPath := flag.String("local", "", "Local path for upload/download")
|
||||
destPath := flag.String("dest", "", "Destination path for move/copy")
|
||||
recursive := flag.Bool("r", false, "Recursive operation")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Use environment variables as fallback if not set at build time or via flags
|
||||
if *serverURL == "" {
|
||||
*serverURL = os.Getenv("NEXTCLOUD_URL")
|
||||
}
|
||||
if *username == "" {
|
||||
*username = os.Getenv("NEXTCLOUD_USER")
|
||||
}
|
||||
if *token == "" {
|
||||
*token = os.Getenv("NEXTCLOUD_TOKEN")
|
||||
}
|
||||
|
||||
if serverURL == nil || username == nil || token == nil || *serverURL == "" || *username == "" || *token == "" {
|
||||
fmt.Println("Error: URL, user, and token are required (set via ldflags, flags, or env vars)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Build config
|
||||
encodedUsername := url.PathEscape(*username)
|
||||
config := Config{
|
||||
URL: *serverURL,
|
||||
User: *username,
|
||||
Token: *token,
|
||||
WebDAV: fmt.Sprintf("%s/remote.php/dav/files/%s", strings.TrimSuffix(*serverURL, "/"), encodedUsername),
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := &Client{
|
||||
Config: config,
|
||||
HTTPClient: &http.Client{},
|
||||
}
|
||||
|
||||
var err error
|
||||
switch *operation {
|
||||
case "list":
|
||||
err = client.list(*path, *recursive)
|
||||
case "upload":
|
||||
err = client.upload(*localPath, *path)
|
||||
case "download":
|
||||
err = client.download(*path, *localPath)
|
||||
case "mkdir":
|
||||
err = client.mkdir(*path)
|
||||
case "delete":
|
||||
err = client.delete(*path)
|
||||
case "move":
|
||||
err = client.move(*path, *destPath)
|
||||
case "copy":
|
||||
err = client.copy(*path, *destPath)
|
||||
case "info":
|
||||
err = client.info(*path)
|
||||
default:
|
||||
fmt.Printf("Unknown operation: %s\n", *operation)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) list(path string, recursive bool) error {
|
||||
depth := "1"
|
||||
if recursive {
|
||||
depth = "infinity"
|
||||
}
|
||||
|
||||
// PROPFIND body
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<d:getlastmodified />
|
||||
<d:getcontentlength />
|
||||
<d:getcontenttype />
|
||||
<oc:permissions />
|
||||
<d:resourcetype />
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>`)
|
||||
|
||||
req, err := http.NewRequest("PROPFIND", c.Config.WebDAV+path, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", depth)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 207 {
|
||||
return fmt.Errorf("unexpected status: %d (expected 207 Multi-Status)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var multiStatus MultiStatus
|
||||
decoder := xml.NewDecoder(resp.Body)
|
||||
if err := decoder.Decode(&multiStatus); err != nil {
|
||||
return fmt.Errorf("xml decode failed: %v", err)
|
||||
}
|
||||
|
||||
// Print results
|
||||
for _, response := range multiStatus.Responses {
|
||||
relPath := strings.TrimPrefix(response.Href, c.Config.WebDAV)
|
||||
if relPath == "" || relPath == c.Config.WebDAV+"/" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Merge props from successful propstats (200 OK)
|
||||
var mergedProp Prop
|
||||
isFolder := false
|
||||
|
||||
for _, propStat := range response.PropStats {
|
||||
if strings.Contains(propStat.Status, "200") {
|
||||
// Merge successful props
|
||||
if propStat.Prop.GetLastModified != "" {
|
||||
mergedProp.GetLastModified = propStat.Prop.GetLastModified
|
||||
}
|
||||
if propStat.Prop.GetContentLength > 0 {
|
||||
mergedProp.GetContentLength = propStat.Prop.GetContentLength
|
||||
}
|
||||
if propStat.Prop.GetContentType != "" {
|
||||
mergedProp.GetContentType = propStat.Prop.GetContentType
|
||||
}
|
||||
if propStat.Prop.GetETag != "" {
|
||||
mergedProp.GetETag = propStat.Prop.GetETag
|
||||
}
|
||||
if propStat.Prop.ResourceType.Collection != "" {
|
||||
mergedProp.ResourceType = propStat.Prop.ResourceType
|
||||
isFolder = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size := mergedProp.GetContentLength
|
||||
if isFolder {
|
||||
size = 0
|
||||
}
|
||||
|
||||
indicator := "-"
|
||||
if isFolder {
|
||||
indicator = "d"
|
||||
}
|
||||
|
||||
fmt.Printf("%s %-10d %s\n", indicator, size, relPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) upload(localPath, remotePath string) error {
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open local file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat file: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PUT", c.Config.WebDAV+remotePath, file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
req.ContentLength = stat.Size()
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 && resp.StatusCode != 200 {
|
||||
return fmt.Errorf("upload failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
fmt.Printf("Uploaded %s to %s\n", localPath, remotePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) download(remotePath, localPath string) error {
|
||||
req, err := http.NewRequest("GET", c.Config.WebDAV+remotePath, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create parent directories if needed
|
||||
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create local directory: %v", err)
|
||||
}
|
||||
|
||||
outFile, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create local file: %v", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
_, err = io.Copy(outFile, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy file: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Downloaded %s to %s\n", remotePath, localPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) mkdir(path string) error {
|
||||
req, err := http.NewRequest("MKCOL", c.Config.WebDAV+path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
||||
return fmt.Errorf("mkdir failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
fmt.Printf("Created directory: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) delete(path string) error {
|
||||
req, err := http.NewRequest("DELETE", c.Config.WebDAV+path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 204 {
|
||||
return fmt.Errorf("delete failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
fmt.Printf("Deleted: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) move(from, to string) error {
|
||||
fullURL := c.Config.WebDAV + from
|
||||
|
||||
req, err := http.NewRequest("MOVE", fullURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
|
||||
dest := c.Config.WebDAV + to
|
||||
req.Header.Set("Destination", dest)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
||||
return fmt.Errorf("move failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
fmt.Printf("Moved %s to %s\n", from, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) copy(from, to string) error {
|
||||
fullURL := c.Config.WebDAV + from
|
||||
|
||||
req, err := http.NewRequest("COPY", fullURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
|
||||
dest := c.Config.WebDAV + to
|
||||
req.Header.Set("Destination", dest)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 {
|
||||
return fmt.Errorf("copy failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
fmt.Printf("Copied %s to %s\n", from, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) info(path string) error {
|
||||
depth := "0"
|
||||
|
||||
// PROPFIND body
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<d:getlastmodified />
|
||||
<d:getcontentlength />
|
||||
<d:getcontenttype />
|
||||
<oc:permissions />
|
||||
<d:resourcetype />
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>`)
|
||||
|
||||
fullURL := c.Config.WebDAV + path
|
||||
req, err := http.NewRequest("PROPFIND", fullURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", depth)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 207 {
|
||||
return fmt.Errorf("info failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var multiStatus MultiStatus
|
||||
decoder := xml.NewDecoder(resp.Body)
|
||||
if err := decoder.Decode(&multiStatus); err != nil {
|
||||
return fmt.Errorf("xml decode failed: %v", err)
|
||||
}
|
||||
|
||||
// Print details
|
||||
for _, response := range multiStatus.Responses {
|
||||
relPath := strings.TrimPrefix(response.Href, c.Config.WebDAV)
|
||||
if relPath == "" || relPath == c.Config.WebDAV+"/" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Merge props from successful propstats (200 OK)
|
||||
var mergedProp Prop
|
||||
isFolder := false
|
||||
|
||||
for _, propStat := range response.PropStats {
|
||||
if strings.Contains(propStat.Status, "200") {
|
||||
// Merge successful props
|
||||
if propStat.Prop.GetLastModified != "" {
|
||||
mergedProp.GetLastModified = propStat.Prop.GetLastModified
|
||||
}
|
||||
if propStat.Prop.GetContentLength > 0 {
|
||||
mergedProp.GetContentLength = propStat.Prop.GetContentLength
|
||||
}
|
||||
if propStat.Prop.GetContentType != "" {
|
||||
mergedProp.GetContentType = propStat.Prop.GetContentType
|
||||
}
|
||||
if propStat.Prop.GetETag != "" {
|
||||
mergedProp.GetETag = propStat.Prop.GetETag
|
||||
}
|
||||
if propStat.Prop.ResourceType.Collection != "" {
|
||||
mergedProp.ResourceType = propStat.Prop.ResourceType
|
||||
isFolder = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size := mergedProp.GetContentLength
|
||||
if isFolder {
|
||||
size = 0
|
||||
}
|
||||
|
||||
fmt.Printf("Path: %s\n", relPath)
|
||||
fmt.Printf(" Type: %s\n", map[bool]string{true: "Folder", false: "File"}[isFolder])
|
||||
fmt.Printf(" Size: %d bytes\n", size)
|
||||
fmt.Printf(" Modified: %s\n", mergedProp.GetLastModified)
|
||||
fmt.Printf(" ETag: %s\n", mergedProp.GetETag)
|
||||
if mergedProp.GetContentType != "" {
|
||||
fmt.Printf(" Type: %s\n", mergedProp.GetContentType)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) request(method, path string, body io.Reader) (*http.Response, error) {
|
||||
fullURL := c.Config.WebDAV + path
|
||||
req, err := http.NewRequest(method, fullURL, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
|
||||
// For PROPFIND, set Content-Type
|
||||
if method == "PROPFIND" {
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
}
|
||||
|
||||
return c.HTTPClient.Do(req)
|
||||
}
|
||||
BIN
tools/go/nextcloud-client/nextcloud-client
Executable file
BIN
tools/go/nextcloud-client/nextcloud-client
Executable file
Binary file not shown.
3
tools/go/nextcloud-contacts/go.mod
Normal file
3
tools/go/nextcloud-contacts/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/wltbagent/nextcloud-contacts
|
||||
|
||||
go 1.24.4
|
||||
436
tools/go/nextcloud-contacts/main.go
Normal file
436
tools/go/nextcloud-contacts/main.go
Normal file
@@ -0,0 +1,436 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Build-time configuration (set via ldflags at compile time)
|
||||
var (
|
||||
BuildServerURL string
|
||||
BuildUsername string
|
||||
BuildToken string
|
||||
)
|
||||
|
||||
// Config holds Nextcloud connection configuration
|
||||
type Config struct {
|
||||
URL string
|
||||
User string
|
||||
Token string
|
||||
CardDAV string
|
||||
}
|
||||
|
||||
// Client is a Nextcloud CardDAV client
|
||||
type Client struct {
|
||||
Config Config
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// MultiStatus represents WebDAV multistatus response
|
||||
type MultiStatus struct {
|
||||
XMLName xml.Name `xml:"multistatus"`
|
||||
Responses []Response `xml:"response"`
|
||||
}
|
||||
|
||||
// Response represents a single WebDAV response
|
||||
type Response struct {
|
||||
Href string `xml:"href"`
|
||||
PropStats []PropStat `xml:"propstat"`
|
||||
}
|
||||
|
||||
// PropStat represents a WebDAV propstat element
|
||||
type PropStat struct {
|
||||
Prop Prop `xml:"prop"`
|
||||
Status string `xml:"status"`
|
||||
}
|
||||
|
||||
// Prop represents WebDAV properties
|
||||
type Prop struct {
|
||||
DisplayName string `xml:"displayname"`
|
||||
GetETag string `xml:"getetag"`
|
||||
}
|
||||
|
||||
// AddressBook represents a CardDAV address book
|
||||
type AddressBook struct {
|
||||
Href string
|
||||
DisplayName string
|
||||
ETag string
|
||||
}
|
||||
|
||||
// Contact represents a vCard contact
|
||||
type Contact struct {
|
||||
UID string
|
||||
FormattedName string
|
||||
Email string
|
||||
Phone string
|
||||
Company string
|
||||
Title string
|
||||
VCard string // Raw vCard content
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Parse flags (with build-time defaults)
|
||||
serverURL := flag.String("url", BuildServerURL, "Nextcloud server URL (e.g., https://cloud.example.com)")
|
||||
username := flag.String("user", BuildUsername, "Nextcloud username")
|
||||
token := flag.String("token", BuildToken, "Nextcloud app token")
|
||||
operation := flag.String("op", "list-books", "Operation: list-books, list-contacts, get-contact, create-contact, delete-contact")
|
||||
bookName := flag.String("book", "contacts", "Address book name (default: contacts)")
|
||||
contactUID := flag.String("uid", "", "Contact UID for get/delete-contact operation")
|
||||
vcardPath := flag.String("vcard", "", "Path to vCard file for create-contact operation")
|
||||
name := flag.String("name", "", "Contact name (for create-contact)")
|
||||
email := flag.String("email", "", "Contact email (for create-contact)")
|
||||
phone := flag.String("phone", "", "Contact phone (for create-contact)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Use environment variables as fallback if not set at build time or via flags
|
||||
if *serverURL == "" {
|
||||
*serverURL = os.Getenv("NEXTCLOUD_URL")
|
||||
}
|
||||
if *username == "" {
|
||||
*username = os.Getenv("NEXTCLOUD_USER")
|
||||
}
|
||||
if *token == "" {
|
||||
*token = os.Getenv("NEXTCLOUD_TOKEN")
|
||||
}
|
||||
|
||||
if serverURL == nil || username == nil || token == nil || *serverURL == "" || *username == "" || *token == "" {
|
||||
fmt.Println("Error: URL, user, and token are required (set via ldflags, flags, or env vars)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Build config
|
||||
encodedUsername := url.PathEscape(*username)
|
||||
config := Config{
|
||||
URL: *serverURL,
|
||||
User: *username,
|
||||
Token: *token,
|
||||
CardDAV: fmt.Sprintf("%s/remote.php/dav/addressbooks/users/%s", strings.TrimSuffix(*serverURL, "/"), encodedUsername),
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := &Client{
|
||||
Config: config,
|
||||
HTTPClient: &http.Client{},
|
||||
}
|
||||
|
||||
var err error
|
||||
switch *operation {
|
||||
case "list-books":
|
||||
err = client.listAddressBooks()
|
||||
case "list-contacts":
|
||||
err = client.listContacts(*bookName)
|
||||
case "get-contact":
|
||||
if *contactUID == "" {
|
||||
fmt.Println("Error: --uid is required for get-contact operation")
|
||||
os.Exit(1)
|
||||
}
|
||||
err = client.getContact(*bookName, *contactUID)
|
||||
case "create-contact":
|
||||
contact := &Contact{
|
||||
FormattedName: *name,
|
||||
Email: *email,
|
||||
Phone: *phone,
|
||||
}
|
||||
if *vcardPath != "" {
|
||||
vcardContent, readErr := os.ReadFile(*vcardPath)
|
||||
if readErr != nil {
|
||||
fmt.Printf("Error reading vCard file: %v\n", readErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
contact.VCard = string(vcardContent)
|
||||
}
|
||||
err = client.createContact(*bookName, contact)
|
||||
case "delete-contact":
|
||||
if *contactUID == "" {
|
||||
fmt.Println("Error: --uid is required for delete-contact operation")
|
||||
os.Exit(1)
|
||||
}
|
||||
err = client.deleteContact(*bookName, *contactUID)
|
||||
default:
|
||||
fmt.Printf("Unknown operation: %s\n", *operation)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) listAddressBooks() error {
|
||||
// PROPFIND body
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname />
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>`)
|
||||
|
||||
req, err := http.NewRequest("PROPFIND", c.Config.CardDAV+"/", strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", "1")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 207 {
|
||||
return fmt.Errorf("unexpected status: %d (expected 207 Multi-Status)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var multiStatus MultiStatus
|
||||
decoder := xml.NewDecoder(resp.Body)
|
||||
if err := decoder.Decode(&multiStatus); err != nil {
|
||||
return fmt.Errorf("xml decode failed: %v", err)
|
||||
}
|
||||
|
||||
// Print address books
|
||||
fmt.Println("Address Books:")
|
||||
fmt.Println("--------------")
|
||||
for _, response := range multiStatus.Responses {
|
||||
relPath := strings.TrimPrefix(response.Href, c.Config.CardDAV)
|
||||
if relPath == "" || !strings.HasSuffix(relPath, "/") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip root directory (no additional path after CardDAV base)
|
||||
if relPath == "/" || strings.TrimSuffix(relPath, "/") == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only show actual address books (have a displayname property)
|
||||
var mergedProp Prop
|
||||
hasDisplayName := false
|
||||
for _, propStat := range response.PropStats {
|
||||
if strings.Contains(propStat.Status, "200") {
|
||||
if propStat.Prop.DisplayName != "" {
|
||||
mergedProp.DisplayName = propStat.Prop.DisplayName
|
||||
hasDisplayName = true
|
||||
}
|
||||
if propStat.Prop.GetETag != "" {
|
||||
mergedProp.GetETag = propStat.Prop.GetETag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasDisplayName {
|
||||
continue
|
||||
}
|
||||
|
||||
bookName := strings.TrimSuffix(relPath, "/")
|
||||
bookName = strings.TrimPrefix(bookName, "/")
|
||||
fmt.Printf("Name: %s\n", mergedProp.DisplayName)
|
||||
fmt.Printf("Path: %s\n", bookName)
|
||||
fmt.Printf("ETag: %s\n", mergedProp.GetETag)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) listContacts(bookName string) error {
|
||||
bookPath := fmt.Sprintf("%s/%s/", c.Config.CardDAV, bookName)
|
||||
|
||||
// PROPFIND body
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>`)
|
||||
|
||||
req, err := http.NewRequest("PROPFIND", bookPath, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", "1")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 207 {
|
||||
return fmt.Errorf("unexpected status: %d (expected 207 Multi-Status)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var multiStatus MultiStatus
|
||||
decoder := xml.NewDecoder(resp.Body)
|
||||
if err := decoder.Decode(&multiStatus); err != nil {
|
||||
return fmt.Errorf("xml decode failed: %v", err)
|
||||
}
|
||||
|
||||
// Print contacts
|
||||
fmt.Printf("Contacts in '%s':\n", bookName)
|
||||
fmt.Println("--------------")
|
||||
contactCount := 0
|
||||
for _, response := range multiStatus.Responses {
|
||||
relPath := strings.TrimPrefix(response.Href, bookPath)
|
||||
if relPath == "" || strings.HasSuffix(relPath, "/") {
|
||||
continue
|
||||
}
|
||||
|
||||
contactCount++
|
||||
var etag string
|
||||
for _, propStat := range response.PropStats {
|
||||
if strings.Contains(propStat.Status, "200") {
|
||||
etag = propStat.Prop.GetETag
|
||||
}
|
||||
}
|
||||
|
||||
// Extract UID from filename (just the filename part, not full path)
|
||||
filename := relPath
|
||||
if idx := strings.LastIndex(relPath, "/"); idx != -1 {
|
||||
filename = relPath[idx+1:]
|
||||
}
|
||||
contactUID := strings.TrimSuffix(filename, ".vcf")
|
||||
fmt.Printf("UID: %s\n", contactUID)
|
||||
fmt.Printf("ETag: %s\n", etag)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if contactCount == 0 {
|
||||
fmt.Println("(No contacts found)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getContact(bookName, contactUID string) error {
|
||||
contactPath := fmt.Sprintf("%s/%s/%s.vcf", c.Config.CardDAV, bookName, contactUID)
|
||||
|
||||
req, err := http.NewRequest("GET", contactPath, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("Accept", "text/vcard; charset=utf-8")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("get contact failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Contact: %s\n", contactUID)
|
||||
fmt.Println("--------------")
|
||||
fmt.Println(string(content))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) createContact(bookName string, contact *Contact) error {
|
||||
bookPath := fmt.Sprintf("%s/%s/", c.Config.CardDAV, bookName)
|
||||
|
||||
// Generate vCard if not provided
|
||||
vcardContent := contact.VCard
|
||||
if vcardContent == "" {
|
||||
if contact.FormattedName == "" {
|
||||
return fmt.Errorf("contact name is required")
|
||||
}
|
||||
|
||||
// Generate a simple vCard
|
||||
uid := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
vcardContent = fmt.Sprintf(`BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
UID:%s
|
||||
FN:%s`, uid, contact.FormattedName)
|
||||
|
||||
if contact.Email != "" {
|
||||
vcardContent += fmt.Sprintf("\nEMAIL;TYPE=INTERNET:%s", contact.Email)
|
||||
}
|
||||
|
||||
if contact.Phone != "" {
|
||||
vcardContent += fmt.Sprintf("\nTEL;TYPE=VOICE:%s", contact.Phone)
|
||||
}
|
||||
|
||||
if contact.Company != "" {
|
||||
vcardContent += fmt.Sprintf("\nORG:%s", contact.Company)
|
||||
}
|
||||
|
||||
if contact.Title != "" {
|
||||
vcardContent += fmt.Sprintf("\nTITLE:%s", contact.Title)
|
||||
}
|
||||
|
||||
vcardContent += "\nEND:VCARD"
|
||||
}
|
||||
|
||||
contactPath := fmt.Sprintf("%s/%s.vcf", bookPath, fmt.Sprintf("%d", time.Now().UnixNano()))
|
||||
|
||||
req, err := http.NewRequest("PUT", contactPath, strings.NewReader(vcardContent))
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
req.Header.Set("Content-Type", "text/vcard; charset=utf-8")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
||||
return fmt.Errorf("create contact failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
fmt.Printf("Contact created successfully\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) deleteContact(bookName, contactUID string) error {
|
||||
contactPath := fmt.Sprintf("%s/%s/%s.vcf", c.Config.CardDAV, bookName, contactUID)
|
||||
|
||||
req, err := http.NewRequest("DELETE", contactPath, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.Config.User, c.Config.Token)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 204 {
|
||||
return fmt.Errorf("delete contact failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
fmt.Printf("Contact deleted: %s\n", contactUID)
|
||||
|
||||
return nil
|
||||
}
|
||||
10
tools/go/nextcloud-mail/go.mod
Normal file
10
tools/go/nextcloud-mail/go.mod
Normal file
@@ -0,0 +1,10 @@
|
||||
module github.com/wltbagent/nextcloud-mail
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/emersion/go-imap v1.2.1
|
||||
|
||||
require (
|
||||
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
)
|
||||
11
tools/go/nextcloud-mail/go.sum
Normal file
11
tools/go/nextcloud-mail/go.sum
Normal file
@@ -0,0 +1,11 @@
|
||||
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
||||
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
||||
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY=
|
||||
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
882
tools/go/nextcloud-mail/main.go
Normal file
882
tools/go/nextcloud-mail/main.go
Normal file
@@ -0,0 +1,882 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
)
|
||||
|
||||
// Build-time configuration (set via ldflags at compile time)
|
||||
var (
|
||||
BuildIMAPServer string
|
||||
BuildIMAPPort string
|
||||
BuildIMAPUser string
|
||||
BuildIMAPPassword string
|
||||
BuildSMTPServer string
|
||||
BuildSMTPPort string
|
||||
BuildSMTPUser string
|
||||
BuildSMTPPassword string
|
||||
BuildUseSSL string
|
||||
BuildIgnoreCerts string
|
||||
)
|
||||
|
||||
// Config holds email connection configuration
|
||||
type Config struct {
|
||||
IMAPServer string
|
||||
IMAPPort string
|
||||
IMAPUser string
|
||||
IMAPPassword string
|
||||
SMTPServer string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPassword string
|
||||
UseSSL bool
|
||||
IgnoreCerts bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Parse flags (with build-time defaults)
|
||||
imapServer := flag.String("imap-server", BuildIMAPServer, "IMAP server address")
|
||||
imapPort := flag.String("imap-port", BuildIMAPPort, "IMAP port (default: 993 for SSL, 143 for STARTTLS)")
|
||||
imapUser := flag.String("imap-user", BuildIMAPUser, "IMAP username")
|
||||
imapPass := flag.String("imap-pass", BuildIMAPPassword, "IMAP password")
|
||||
smtpServer := flag.String("smtp-server", BuildSMTPServer, "SMTP server address")
|
||||
smtpPort := flag.String("smtp-port", BuildSMTPPort, "SMTP port (default: 465 for SSL, 587 for STARTTLS)")
|
||||
smtpUser := flag.String("smtp-user", BuildSMTPUser, "SMTP username")
|
||||
smtpPass := flag.String("smtp-pass", BuildSMTPPassword, "SMTP password")
|
||||
operation := flag.String("op", "list-folders", "Operation: list-folders, list-messages, get-message, send-email, delete-messages, move-messages, search")
|
||||
folder := flag.String("folder", "INBOX", "IMAP folder to operate on")
|
||||
from := flag.String("from", "", "Email sender address (for send-email)")
|
||||
to := flag.String("to", "", "Email recipient(s) (comma-separated, for send-email)")
|
||||
subject := flag.String("subject", "", "Email subject (for send-email)")
|
||||
body := flag.String("body", "", "Email body (for send-email)")
|
||||
attachments := flag.String("attachments", "", "Attachment file paths (comma-separated)")
|
||||
saveDir := flag.String("save-dir", ".", "Directory to save attachments")
|
||||
useSSL := flag.Bool("ssl", BuildUseSSL == "true", "Use SSL/TLS (default: true)")
|
||||
ignoreCerts := flag.Bool("ignore-certs", BuildIgnoreCerts == "true", "Ignore certificate validity")
|
||||
page := flag.Int("page", 1, "Page number (1-indexed)")
|
||||
pageSize := flag.Int("page-size", 25, "Messages per page")
|
||||
searchQuery := flag.String("query", "", "Search query (server-side search)")
|
||||
uids := flag.String("uids", "", "Message UIDs (comma-separated, for delete/move)")
|
||||
destFolder := flag.String("dest-folder", "", "Destination folder (for move-messages)")
|
||||
listAttachments := flag.Bool("list-attachments", false, "List message attachments only")
|
||||
saveAttachments := flag.Bool("save-attachments", false, "Save message attachments")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Build config
|
||||
config := Config{
|
||||
IMAPServer: *imapServer,
|
||||
IMAPPort: *imapPort,
|
||||
IMAPUser: *imapUser,
|
||||
IMAPPassword: *imapPass,
|
||||
SMTPServer: *smtpServer,
|
||||
SMTPPort: *smtpPort,
|
||||
SMTPUser: *smtpUser,
|
||||
SMTPPassword: *smtpPass,
|
||||
UseSSL: *useSSL,
|
||||
IgnoreCerts: *ignoreCerts,
|
||||
}
|
||||
|
||||
// Set default ports
|
||||
if config.IMAPPort == "" {
|
||||
if config.UseSSL {
|
||||
config.IMAPPort = "993"
|
||||
} else {
|
||||
config.IMAPPort = "143"
|
||||
}
|
||||
}
|
||||
if config.SMTPPort == "" {
|
||||
if config.UseSSL {
|
||||
config.SMTPPort = "465"
|
||||
} else {
|
||||
config.SMTPPort = "587"
|
||||
}
|
||||
}
|
||||
|
||||
// Execute operation
|
||||
switch *operation {
|
||||
case "list-folders":
|
||||
err := listFolders(config)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
case "list-messages":
|
||||
if *folder == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --folder is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
err := listMessages(config, *folder, *page, *pageSize)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
case "get-message":
|
||||
if *folder == "" || *uids == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --folder and --uids are required")
|
||||
os.Exit(1)
|
||||
}
|
||||
err := getMessage(config, *folder, *uids, *listAttachments, *saveAttachments, *saveDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
case "send-email":
|
||||
if *from == "" || *to == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --from and --to are required")
|
||||
os.Exit(1)
|
||||
}
|
||||
var attachFiles []string
|
||||
if *attachments != "" {
|
||||
attachFiles = strings.Split(*attachments, ",")
|
||||
}
|
||||
err := sendEmail(config, *from, strings.Split(*to, ","), *subject, *body, attachFiles)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Email sent successfully")
|
||||
|
||||
case "delete-messages":
|
||||
if *folder == "" || *uids == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --folder and --uids are required")
|
||||
os.Exit(1)
|
||||
}
|
||||
err := deleteMessages(config, *folder, *uids)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Messages deleted successfully")
|
||||
|
||||
case "move-messages":
|
||||
if *folder == "" || *uids == "" || *destFolder == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --folder, --uids, and --dest-folder are required")
|
||||
os.Exit(1)
|
||||
}
|
||||
err := moveMessages(config, *folder, *uids, *destFolder)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Messages moved successfully")
|
||||
|
||||
case "search":
|
||||
if *folder == "" || *searchQuery == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --folder and --query are required")
|
||||
os.Exit(1)
|
||||
}
|
||||
err := searchMessages(config, *folder, *searchQuery)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown operation: %s\n", *operation)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// connectIMAP establishes a connection to IMAP server
|
||||
func connectIMAP(config Config) (*client.Client, error) {
|
||||
serverAddr := fmt.Sprintf("%s:%s", config.IMAPServer, config.IMAPPort)
|
||||
|
||||
var c *client.Client
|
||||
var err error
|
||||
|
||||
if config.UseSSL {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: config.IgnoreCerts,
|
||||
}
|
||||
c, err = client.DialTLS(serverAddr, tlsConfig)
|
||||
} else {
|
||||
c, err = client.Dial(serverAddr)
|
||||
if err == nil && !config.UseSSL {
|
||||
// Try STARTTLS
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: config.IgnoreCerts,
|
||||
}
|
||||
err = c.StartTLS(tlsConfig)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to IMAP server: %w", err)
|
||||
}
|
||||
|
||||
// Login
|
||||
err = c.Login(config.IMAPUser, config.IMAPPassword)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("IMAP login failed: %w", err)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// listFolders lists all IMAP folders
|
||||
func listFolders(config Config) error {
|
||||
c, err := connectIMAP(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// List mailboxes
|
||||
mailboxes := make(chan *imap.MailboxInfo, 10)
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
done <- c.List("", "*", mailboxes)
|
||||
}()
|
||||
|
||||
fmt.Println("IMAP Folders:")
|
||||
for m := range mailboxes {
|
||||
fmt.Printf(" %s\n", m.Name)
|
||||
}
|
||||
|
||||
if err := <-done; err != nil {
|
||||
return fmt.Errorf("failed to list folders: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// listMessages lists messages in a folder with pagination
|
||||
func listMessages(config Config, folder string, page, pageSize int) error {
|
||||
c, err := connectIMAP(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Select mailbox
|
||||
mbox, err := c.Select(folder, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select folder %s: %w", folder, err)
|
||||
}
|
||||
|
||||
// Calculate message range
|
||||
totalMessages := mbox.Messages
|
||||
if totalMessages == 0 {
|
||||
fmt.Println("No messages in folder")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate pagination (1-indexed, newest first)
|
||||
offset := uint32((page - 1) * pageSize)
|
||||
start := totalMessages - offset
|
||||
end := start - uint32(pageSize) + 1
|
||||
|
||||
if end < 1 {
|
||||
end = 1
|
||||
}
|
||||
if start > totalMessages {
|
||||
start = totalMessages
|
||||
}
|
||||
if start < end {
|
||||
fmt.Println("No messages on this page")
|
||||
return nil
|
||||
}
|
||||
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddRange(start, end)
|
||||
|
||||
// Fetch messages
|
||||
messages := make(chan *imap.Message, pageSize)
|
||||
done := make(chan error, 1)
|
||||
|
||||
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
|
||||
|
||||
go func() {
|
||||
done <- c.Fetch(seqset, items, messages)
|
||||
}()
|
||||
|
||||
fmt.Printf("Messages in %s (Page %d of %d, showing %d-%d of %d):\n",
|
||||
folder, page, (int(totalMessages)+pageSize-1)/pageSize, end, start, totalMessages)
|
||||
fmt.Println()
|
||||
|
||||
for msg := range messages {
|
||||
fmt.Printf("UID: %d\n", msg.Uid)
|
||||
fmt.Printf(" From: %s\n", msg.Envelope.From)
|
||||
fmt.Printf(" To: %s\n", msg.Envelope.To)
|
||||
if len(msg.Envelope.Subject) > 0 {
|
||||
fmt.Printf(" Subject: %s\n", msg.Envelope.Subject)
|
||||
} else {
|
||||
fmt.Printf(" Subject: (no subject)\n")
|
||||
}
|
||||
if !msg.Envelope.Date.IsZero() {
|
||||
fmt.Printf(" Date: %s\n", msg.Envelope.Date.Format(time.RFC1123))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if err := <-done; err != nil {
|
||||
return fmt.Errorf("failed to fetch messages: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getMessage retrieves message content
|
||||
func getMessage(config Config, folder, uidsStr string, listAttachments, saveAttachments bool, saveDir string) error {
|
||||
c, err := connectIMAP(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Parse UIDs
|
||||
uidStrings := strings.Split(uidsStr, ",")
|
||||
uids := make([]uint32, 0, len(uidStrings))
|
||||
for _, uidStr := range uidStrings {
|
||||
var uid uint32
|
||||
_, err := fmt.Sscanf(strings.TrimSpace(uidStr), "%d", &uid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid UID %s: %w", uidStr, err)
|
||||
}
|
||||
uids = append(uids, uid)
|
||||
}
|
||||
|
||||
// Select mailbox
|
||||
_, err = c.Select(folder, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select folder %s: %w", folder, err)
|
||||
}
|
||||
|
||||
// Create save directory if needed
|
||||
if saveAttachments {
|
||||
err = os.MkdirAll(saveDir, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create save directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch messages
|
||||
for _, uid := range uids {
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddNum(uid)
|
||||
|
||||
// Determine what to fetch
|
||||
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
|
||||
if listAttachments || saveAttachments {
|
||||
items = append(items, imap.FetchBodyStructure)
|
||||
}
|
||||
|
||||
messages := make(chan *imap.Message, 1)
|
||||
err := c.UidFetch(seqset, items, messages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch message %d: %w", uid, err)
|
||||
}
|
||||
|
||||
msg := <-messages
|
||||
if msg == nil {
|
||||
fmt.Printf("Message UID %d not found\n", uid)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("UID: %d\n", uid)
|
||||
fmt.Printf(" From: %s\n", msg.Envelope.From)
|
||||
fmt.Printf(" Subject: %s\n", msg.Envelope.Subject)
|
||||
|
||||
// Process attachments
|
||||
if listAttachments || saveAttachments {
|
||||
if msg.BodyStructure == nil {
|
||||
fmt.Println(" No body structure available")
|
||||
continue
|
||||
}
|
||||
|
||||
attachments := extractAttachmentInfo(msg.BodyStructure, "")
|
||||
if len(attachments) == 0 {
|
||||
fmt.Println(" No attachments found")
|
||||
}
|
||||
|
||||
if listAttachments {
|
||||
fmt.Printf(" Attachments (%d):\n", len(attachments))
|
||||
for i, att := range attachments {
|
||||
fmt.Printf(" %d. %s (%s) - Part: %s\n", i+1, att.Filename, att.ContentType, att.PartPath)
|
||||
}
|
||||
}
|
||||
|
||||
if saveAttachments {
|
||||
fmt.Printf(" Saving attachments to %s:\n", saveDir)
|
||||
for _, att := range attachments {
|
||||
// Fetch the attachment body part
|
||||
if att.PartPath == "" {
|
||||
fmt.Printf(" Skipping %s: no part path\n", att.Filename)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse part path to create BodySectionName
|
||||
section := imap.BodySectionName{}
|
||||
pathParts := strings.Split(att.PartPath, ".")
|
||||
section.Path = make([]int, len(pathParts))
|
||||
for i, p := range pathParts {
|
||||
_, err := fmt.Sscanf(p, "%d", §ion.Path[i])
|
||||
if err != nil {
|
||||
fmt.Printf(" Error parsing part path for %s: %v\n", att.Filename, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
items := []imap.FetchItem{section.FetchItem()}
|
||||
|
||||
// Fetch this specific message again with the body part
|
||||
msgSeqSet := new(imap.SeqSet)
|
||||
msgSeqSet.AddNum(uid)
|
||||
|
||||
attachMessages := make(chan *imap.Message, 1)
|
||||
err := c.UidFetch(msgSeqSet, items, attachMessages)
|
||||
if err != nil {
|
||||
fmt.Printf(" Error fetching %s: %v\n", att.Filename, err)
|
||||
continue
|
||||
}
|
||||
|
||||
attachMsg := <-attachMessages
|
||||
if attachMsg == nil {
|
||||
fmt.Printf(" Error: attachment not found for %s\n", att.Filename)
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the literal data for this section
|
||||
var reader io.Reader
|
||||
for _, r := range attachMsg.Body {
|
||||
reader = r
|
||||
break
|
||||
}
|
||||
|
||||
if reader == nil {
|
||||
fmt.Printf(" Error: no data for attachment %s\n", att.Filename)
|
||||
continue
|
||||
}
|
||||
|
||||
// Read attachment data
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
fmt.Printf(" Error reading %s: %v\n", att.Filename, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Save to disk
|
||||
safeFilename := filepath.Join(saveDir, att.Filename)
|
||||
err = os.WriteFile(safeFilename, data, 0644)
|
||||
if err != nil {
|
||||
fmt.Printf(" Error saving %s: %v\n", att.Filename, err)
|
||||
} else {
|
||||
fmt.Printf(" Saved: %s (%d bytes)\n", att.Filename, len(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Print body text - fetch the full message body
|
||||
bodyItems := []imap.FetchItem{imap.FetchBody}
|
||||
bodySeqSet := new(imap.SeqSet)
|
||||
bodySeqSet.AddNum(uid)
|
||||
|
||||
bodyMessages := make(chan *imap.Message, 1)
|
||||
err := c.UidFetch(bodySeqSet, bodyItems, bodyMessages)
|
||||
if err != nil {
|
||||
fmt.Printf(" Error fetching message body: %v\n", err)
|
||||
} else {
|
||||
bodyMsg := <-bodyMessages
|
||||
if bodyMsg != nil {
|
||||
for _, section := range bodyMsg.Body {
|
||||
reader := section
|
||||
_, err := io.Copy(os.Stdout, reader)
|
||||
if err != nil {
|
||||
fmt.Printf(" Error reading message body: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Attachment represents an email attachment
|
||||
type Attachment struct {
|
||||
Filename string
|
||||
ContentType string
|
||||
Data []byte
|
||||
Size int
|
||||
PartPath string // IMAP part path (e.g., "1.2")
|
||||
}
|
||||
|
||||
// extractAttachmentInfo extracts attachment information from BodyStructure
|
||||
func extractAttachmentInfo(bs *imap.BodyStructure, partPath string) []Attachment {
|
||||
var attachments []Attachment
|
||||
|
||||
if bs == nil {
|
||||
return attachments
|
||||
}
|
||||
|
||||
// Build part path for this part
|
||||
var currentPath string
|
||||
if partPath == "" {
|
||||
// Root part doesn't have a path in IMAP
|
||||
currentPath = ""
|
||||
} else {
|
||||
currentPath = partPath
|
||||
}
|
||||
|
||||
// Check if this part is an attachment
|
||||
filename := bs.DispositionParams["filename"]
|
||||
if filename == "" {
|
||||
filename = bs.Params["name"]
|
||||
}
|
||||
|
||||
if filename != "" && (strings.HasPrefix(bs.Disposition, "attachment") ||
|
||||
strings.HasPrefix(bs.MIMEType, "application/") ||
|
||||
strings.HasPrefix(bs.MIMEType, "image/")) {
|
||||
// This is an attachment
|
||||
attachments = append(attachments, Attachment{
|
||||
Filename: filename,
|
||||
ContentType: bs.MIMEType,
|
||||
PartPath: currentPath,
|
||||
})
|
||||
}
|
||||
|
||||
// Recursively process multipart
|
||||
if len(bs.Parts) > 0 {
|
||||
for i, part := range bs.Parts {
|
||||
var subPath string
|
||||
if currentPath == "" {
|
||||
subPath = fmt.Sprintf("%d", i+1)
|
||||
} else {
|
||||
subPath = fmt.Sprintf("%s.%d", currentPath, i+1)
|
||||
}
|
||||
attachments = append(attachments, extractAttachmentInfo(part, subPath)...)
|
||||
}
|
||||
}
|
||||
|
||||
return attachments
|
||||
}
|
||||
|
||||
// extractAttachments extracts attachments from a message (deprecated, use extractAttachmentInfo)
|
||||
func extractAttachments(msg *imap.Message) ([]Attachment, error) {
|
||||
return []Attachment{}, nil // Deprecated
|
||||
}
|
||||
|
||||
// deleteMessages deletes messages by UID
|
||||
func deleteMessages(config Config, folder, uidsStr string) error {
|
||||
c, err := connectIMAP(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Select mailbox
|
||||
mbox, err := c.Select(folder, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select folder %s: %w", folder, err)
|
||||
}
|
||||
|
||||
// Check if folder is read-only
|
||||
if mbox.ReadOnly {
|
||||
return fmt.Errorf("folder is read-only")
|
||||
}
|
||||
|
||||
// Parse UIDs
|
||||
uidStrings := strings.Split(uidsStr, ",")
|
||||
seqset := new(imap.SeqSet)
|
||||
for _, uidStr := range uidStrings {
|
||||
var uid uint32
|
||||
_, err := fmt.Sscanf(strings.TrimSpace(uidStr), "%d", &uid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid UID %s: %w", uidStr, err)
|
||||
}
|
||||
seqset.AddNum(uid)
|
||||
}
|
||||
|
||||
// Delete messages
|
||||
if err := c.Store(seqset, imap.FormatFlagsOp(imap.AddFlags, true), []interface{}{imap.DeletedFlag}, nil); err != nil {
|
||||
return fmt.Errorf("failed to mark messages for deletion: %w", err)
|
||||
}
|
||||
|
||||
if err := c.Expunge(nil); err != nil {
|
||||
return fmt.Errorf("failed to expunge messages: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// moveMessages moves messages between folders
|
||||
func moveMessages(config Config, folder, uidsStr, destFolder string) error {
|
||||
c, err := connectIMAP(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Parse UIDs
|
||||
uidStrings := strings.Split(uidsStr, ",")
|
||||
uids := make([]uint32, 0, len(uidStrings))
|
||||
for _, uidStr := range uidStrings {
|
||||
var uid uint32
|
||||
_, err := fmt.Sscanf(strings.TrimSpace(uidStr), "%d", &uid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid UID %s: %w", uidStr, err)
|
||||
}
|
||||
uids = append(uids, uid)
|
||||
}
|
||||
|
||||
// Select source mailbox
|
||||
mbox, err := c.Select(folder, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select folder %s: %w", folder, err)
|
||||
}
|
||||
|
||||
// Check if folder is read-only
|
||||
if mbox.ReadOnly {
|
||||
return fmt.Errorf("folder is read-only")
|
||||
}
|
||||
|
||||
// Check for MOVE capability
|
||||
caps, err := c.Capability()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get capabilities: %w", err)
|
||||
}
|
||||
if caps["MOVE"] {
|
||||
// Use MOVE extension if available
|
||||
seqset := new(imap.SeqSet)
|
||||
for _, uid := range uids {
|
||||
seqset.AddNum(uid)
|
||||
}
|
||||
if err := c.UidMove(seqset, destFolder); err != nil {
|
||||
return fmt.Errorf("failed to move messages: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Fallback: copy then delete
|
||||
seqset := new(imap.SeqSet)
|
||||
for _, uid := range uids {
|
||||
seqset.AddNum(uid)
|
||||
}
|
||||
|
||||
// Copy to destination
|
||||
if err := c.UidCopy(seqset, destFolder); err != nil {
|
||||
return fmt.Errorf("failed to copy messages: %w", err)
|
||||
}
|
||||
|
||||
// Mark as deleted and expunge
|
||||
if err := c.Store(seqset, imap.FormatFlagsOp(imap.AddFlags, true), []interface{}{imap.DeletedFlag}, nil); err != nil {
|
||||
return fmt.Errorf("failed to mark messages for deletion: %w", err)
|
||||
}
|
||||
|
||||
if err := c.Expunge(nil); err != nil {
|
||||
return fmt.Errorf("failed to expunge messages: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchMessages performs server-side search
|
||||
func searchMessages(config Config, folder, query string) error {
|
||||
c, err := connectIMAP(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Select mailbox
|
||||
_, err = c.Select(folder, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select folder %s: %w", folder, err)
|
||||
}
|
||||
|
||||
// Build search criteria (simple keyword search for now)
|
||||
criteria := imap.NewSearchCriteria()
|
||||
criteria.Text = []string{query}
|
||||
|
||||
ids, err := c.Search(criteria)
|
||||
if err != nil {
|
||||
return fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
fmt.Printf("No messages matching query: %s\n", query)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d messages matching '%s' in %s:\n", len(ids), query, folder)
|
||||
|
||||
// Fetch results
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddNum(ids...)
|
||||
|
||||
messages := make(chan *imap.Message, len(ids))
|
||||
done := make(chan error, 1)
|
||||
|
||||
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
|
||||
|
||||
go func() {
|
||||
done <- c.Fetch(seqset, items, messages)
|
||||
}()
|
||||
|
||||
for msg := range messages {
|
||||
fmt.Printf("UID: %d\n", msg.Uid)
|
||||
fmt.Printf(" From: %s\n", msg.Envelope.From)
|
||||
if len(msg.Envelope.Subject) > 0 {
|
||||
fmt.Printf(" Subject: %s\n", msg.Envelope.Subject)
|
||||
} else {
|
||||
fmt.Printf(" Subject: (no subject)\n")
|
||||
}
|
||||
if !msg.Envelope.Date.IsZero() {
|
||||
fmt.Printf(" Date: %s\n", msg.Envelope.Date.Format(time.RFC1123))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if err := <-done; err != nil {
|
||||
return fmt.Errorf("failed to fetch search results: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendEmail sends an email via SMTP
|
||||
func sendEmail(config Config, from string, to []string, subject, body string, attachments []string) error {
|
||||
smtpAddr := fmt.Sprintf("%s:%s", config.SMTPServer, config.SMTPPort)
|
||||
|
||||
// Build message
|
||||
var msg strings.Builder
|
||||
msg.WriteString(fmt.Sprintf("From: %s\n", from))
|
||||
msg.WriteString(fmt.Sprintf("To: %s\n", strings.Join(to, ", ")))
|
||||
msg.WriteString(fmt.Sprintf("Subject: %s\n", subject))
|
||||
msg.WriteString("MIME-Version: 1.0\n")
|
||||
|
||||
if len(attachments) == 0 {
|
||||
msg.WriteString("Content-Type: text/plain; charset=utf-8\n")
|
||||
msg.WriteString("\n")
|
||||
msg.WriteString(body)
|
||||
} else {
|
||||
// Multipart with attachments
|
||||
boundary := "NextcloudMail_" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
msg.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\n", boundary))
|
||||
msg.WriteString("\n")
|
||||
|
||||
// Text body
|
||||
msg.WriteString(fmt.Sprintf("--%s\n", boundary))
|
||||
msg.WriteString("Content-Type: text/plain; charset=utf-8\n")
|
||||
msg.WriteString("\n")
|
||||
msg.WriteString(body)
|
||||
msg.WriteString("\n")
|
||||
|
||||
// Attachments
|
||||
for _, file := range attachments {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read attachment %s: %w", file, err)
|
||||
}
|
||||
|
||||
filename := filepath.Base(file)
|
||||
msg.WriteString(fmt.Sprintf("--%s\n", boundary))
|
||||
msg.WriteString("Content-Type: application/octet-stream\n")
|
||||
msg.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=\"%s\"\n", filename))
|
||||
msg.WriteString("Content-Transfer-Encoding: base64\n")
|
||||
msg.WriteString("\n")
|
||||
msg.WriteString(encodeBase64(data))
|
||||
msg.WriteString("\n")
|
||||
}
|
||||
|
||||
msg.WriteString(fmt.Sprintf("--%s--\n", boundary))
|
||||
}
|
||||
|
||||
// Connect and send
|
||||
var auth smtp.Auth
|
||||
if config.SMTPUser != "" && config.SMTPPassword != "" {
|
||||
auth = smtp.PlainAuth("", config.SMTPUser, config.SMTPPassword, config.SMTPServer)
|
||||
}
|
||||
|
||||
if config.UseSSL {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: config.IgnoreCerts,
|
||||
ServerName: config.SMTPServer,
|
||||
}
|
||||
|
||||
// Connect via TLS
|
||||
conn, err := tls.Dial("tcp", smtpAddr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect via TLS: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, config.SMTPServer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
defer client.Quit()
|
||||
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(from); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
|
||||
for _, addr := range to {
|
||||
if err := client.Rcpt(addr); err != nil {
|
||||
return fmt.Errorf("failed to add recipient %s: %w", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send data: %w", err)
|
||||
}
|
||||
defer wc.Close()
|
||||
|
||||
_, err = wc.Write([]byte(msg.String()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write message: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Non-SSL connection with STARTTLS
|
||||
err := smtp.SendMail(smtpAddr, auth, from, to, []byte(msg.String()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeBase64 encodes data to base64 with line wrapping
|
||||
func encodeBase64(data []byte) string {
|
||||
const maxLineLen = 76
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
// Add line breaks every maxLineLen characters
|
||||
var result strings.Builder
|
||||
for i := 0; i < len(encoded); i += maxLineLen {
|
||||
end := i + maxLineLen
|
||||
if end > len(encoded) {
|
||||
end = len(encoded)
|
||||
}
|
||||
result.WriteString(encoded[i:end])
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
Reference in New Issue
Block a user