A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix linebreaks on reopening drafts

sspaeti c061c9e6 b0593e2e

+170 -5
+58 -5
internal/smtp/sender.go
··· 258 258 // BuildDraftMessage constructs a raw MIME draft for IMAP APPEND. 259 259 // Unlike SMTP delivery, drafts should retain the Bcc header so the user's 260 260 // intent survives round-tripping through Drafts. 261 + // Drafts are stored as plain text only (no HTML conversion) to preserve the 262 + // original markdown formatting exactly during save/load cycles. 261 263 func BuildDraftMessage(from, to, cc, bcc, subject, markdownBody string, attachments []string) ([]byte, error) { 262 - htmlBody, err := render.ToHTML(markdownBody) 263 - if err != nil { 264 - return nil, fmt.Errorf("markdown to html: %w", err) 265 - } 266 - return buildMessageWithBCC(from, to, cc, bcc, subject, markdownBody, htmlBody, attachments) 264 + // Pass empty htmlBody to store plain text only 265 + return buildMessageWithBCC(from, to, cc, bcc, subject, markdownBody, "", attachments) 267 266 } 268 267 269 268 // inlineImage holds a local image path and its assigned Content-ID. ··· 331 330 332 331 hasFiles := len(attachments) > 0 333 332 hasImages := len(inlines) > 0 333 + hasHTML := htmlBody != "" 334 334 335 335 switch { 336 + case !hasHTML && !hasFiles: 337 + // Plain text only (drafts): simple text/plain message 338 + hdr("From", from) 339 + hdr("To", to) 340 + if cc != "" { 341 + hdr("Cc", cc) 342 + } 343 + if bcc != "" { 344 + hdr("Bcc", bcc) 345 + } 346 + hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) 347 + hdr("Date", time.Now().Format(time.RFC1123Z)) 348 + hdr("Message-ID", "<"+msgID+"@neomd>") 349 + hdr("MIME-Version", "1.0") 350 + hdr("Content-Type", "text/plain; charset=utf-8") 351 + hdr("Content-Transfer-Encoding", "quoted-printable") 352 + hdr("X-Mailer", "neomd") 353 + b.WriteString("\r\n") 354 + writeQP(&b, plainText) 355 + 356 + case !hasHTML && hasFiles: 357 + // Plain text + attachments (drafts with files): multipart/mixed 358 + mixedBoundary, err := randomBoundary() 359 + if err != nil { 360 + return nil, err 361 + } 362 + hdr("From", from) 363 + hdr("To", to) 364 + if cc != "" { 365 + hdr("Cc", cc) 366 + } 367 + if bcc != "" { 368 + hdr("Bcc", bcc) 369 + } 370 + hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) 371 + hdr("Date", time.Now().Format(time.RFC1123Z)) 372 + hdr("Message-ID", "<"+msgID+"@neomd>") 373 + hdr("MIME-Version", "1.0") 374 + hdr("Content-Type", `multipart/mixed; boundary="`+mixedBoundary+`"`) 375 + hdr("X-Mailer", "neomd") 376 + b.WriteString("\r\n") 377 + fmt.Fprintf(&b, "--%s\r\n", mixedBoundary) 378 + b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") 379 + b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") 380 + writeQP(&b, plainText) 381 + b.WriteString("\r\n") 382 + for _, path := range attachments { 383 + if err := writeAttachment(&b, mixedBoundary, path); err != nil { 384 + return nil, fmt.Errorf("attachment %s: %w", path, err) 385 + } 386 + } 387 + fmt.Fprintf(&b, "--%s--\r\n", mixedBoundary) 388 + 336 389 case !hasImages && !hasFiles: 337 390 // Simple: multipart/alternative only 338 391 writeHeaders(`multipart/alternative; boundary="` + altBoundary + `"`)
+112
internal/smtp/sender_test.go
··· 386 386 } 387 387 } 388 388 389 + func TestBuildDraftMessage_PlainTextOnly(t *testing.T) { 390 + // Test that drafts are stored as plain text only, no HTML conversion. 391 + // This ensures markdown formatting survives the save/load cycle. 392 + markdownBody := `thello there 393 + 394 + -- 395 + **Simon Späti** 396 + Data Engineer & Technical Author, SSP Data GmbH 397 + 398 + Connect: [LinkedIn](https://li.ssp.sh/) | [Bluesky](https://bs.ssp.sh/) | [GitHub](https://gh.ssp.sh/) 399 + Explore: [Website](https://ssp.sh/) | [Vault](https://vault.ssp.sh/) | [Book](https://dedp.online/) | [Services](https://ssp.sh/services) 400 + 401 + *sent from [neomd](https://neomd.ssp.sh)*` 402 + 403 + raw, err := BuildDraftMessage( 404 + "Simon Späti <simu@sspaeti.com>", 405 + "sspaeti@hey.com", 406 + "", 407 + "sspaeti@hey.com", 408 + "test 5555", 409 + markdownBody, 410 + nil, 411 + ) 412 + if err != nil { 413 + t.Fatalf("BuildDraftMessage: %v", err) 414 + } 415 + 416 + msg, mediaType, _ := parseMIME(t, raw) 417 + 418 + // Verify it's plain text, NOT multipart/alternative 419 + if mediaType != "text/plain" { 420 + t.Errorf("expected text/plain for draft, got %s", mediaType) 421 + } 422 + 423 + // Verify BCC header is present (drafts keep BCC) 424 + bcc := msg.Header.Get("Bcc") 425 + if bcc != "sspaeti@hey.com" { 426 + t.Errorf("Bcc header: got %q, want %q", bcc, "sspaeti@hey.com") 427 + } 428 + 429 + // Read the body and verify it matches exactly (no HTML conversion artifacts) 430 + body, err := io.ReadAll(msg.Body) 431 + if err != nil { 432 + t.Fatalf("read body: %v", err) 433 + } 434 + 435 + bodyStr := string(body) 436 + // The body is quoted-printable encoded, but for ASCII text it should be mostly readable. 437 + // Check for key markers that would be munged by HTML conversion: 438 + if !strings.Contains(bodyStr, "**Simon Sp") { // ** should be preserved, not converted to <strong> 439 + t.Error("markdown bold syntax (**) not preserved in draft body") 440 + } 441 + if !strings.Contains(bodyStr, "[LinkedIn]") { // markdown links should be preserved 442 + t.Error("markdown link syntax [] not preserved in draft body") 443 + } 444 + if !strings.Contains(bodyStr, "*sent from") { // * for italics should be preserved 445 + t.Error("markdown italic syntax (*) not preserved in draft body") 446 + } 447 + if strings.Contains(bodyStr, "<p>") || strings.Contains(bodyStr, "<strong>") { 448 + t.Error("draft body contains HTML tags - should be plain text only") 449 + } 450 + } 451 + 452 + func TestBuildDraftMessage_WithAttachment(t *testing.T) { 453 + dir := t.TempDir() 454 + attPath := filepath.Join(dir, "file.txt") 455 + if err := os.WriteFile(attPath, []byte("content"), 0644); err != nil { 456 + t.Fatal(err) 457 + } 458 + 459 + raw, err := BuildDraftMessage( 460 + "Alice <alice@example.com>", 461 + "Bob <bob@example.com>", 462 + "", 463 + "", 464 + "Draft with attachment", 465 + "body text", 466 + []string{attPath}, 467 + ) 468 + if err != nil { 469 + t.Fatalf("BuildDraftMessage: %v", err) 470 + } 471 + 472 + _, mediaType, params := parseMIME(t, raw) 473 + if mediaType != "multipart/mixed" { 474 + t.Fatalf("expected multipart/mixed for draft with attachment, got %s", mediaType) 475 + } 476 + 477 + msg, _ := mail.ReadMessage(bytes.NewReader(raw)) 478 + mr := multipart.NewReader(msg.Body, params["boundary"]) 479 + 480 + // First part should be plain text (not multipart/alternative) 481 + part0, err := mr.NextPart() 482 + if err != nil { 483 + t.Fatalf("NextPart 0: %v", err) 484 + } 485 + ct0, _, _ := mime.ParseMediaType(part0.Header.Get("Content-Type")) 486 + if ct0 != "text/plain" { 487 + t.Errorf("first part: expected text/plain, got %s", ct0) 488 + } 489 + 490 + // Second part should be the attachment 491 + part1, err := mr.NextPart() 492 + if err != nil { 493 + t.Fatalf("NextPart 1: %v", err) 494 + } 495 + ct1, _, _ := mime.ParseMediaType(part1.Header.Get("Content-Type")) 496 + if ct1 != "text/plain" { 497 + t.Errorf("attachment content-type: expected text/plain, got %s", ct1) 498 + } 499 + } 500 + 389 501 func TestInferSMTPUseTLS(t *testing.T) { 390 502 tests := []struct { 391 503 name string